From 760b6c4f335d9b8003d978962ed13dc9a1276ba6 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sat, 8 Aug 2026 15:27:24 +0000 Subject: [PATCH 1/3] feat: add avatar upload functionality for users and projects - Implemented avatar upload initiation and completion endpoints for user profiles. - Added avatar key fields to users and projects in the database schema. - Enhanced user and project handlers to support avatar URL resolution. - Updated tests to cover avatar upload scenarios, including validation for content type and file size. - Introduced a fake storage client for testing avatar uploads. - Modified response presenters to include avatar URLs in user and project responses. --- apps/web/public/provider-logos/anthropic.svg | 1 + apps/web/public/provider-logos/azure.svg | 1 + apps/web/public/provider-logos/bedrock.svg | 1 + .../web/public/provider-logos/claude-code.svg | 1 + apps/web/public/provider-logos/codex.svg | 1 + apps/web/public/provider-logos/cohere.svg | 1 + apps/web/public/provider-logos/deepseek.svg | 1 + apps/web/public/provider-logos/gemini-cli.svg | 1 + apps/web/public/provider-logos/gemini.svg | 1 + apps/web/public/provider-logos/groq.svg | 1 + apps/web/public/provider-logos/meta_llama.svg | 1 + apps/web/public/provider-logos/mistral.svg | 1 + apps/web/public/provider-logos/ollama.svg | 1 + apps/web/public/provider-logos/openai.svg | 1 + apps/web/public/provider-logos/openrouter.svg | 1 + apps/web/public/provider-logos/perplexity.svg | 1 + apps/web/public/provider-logos/vertex_ai.svg | 1 + apps/web/public/provider-logos/xai.svg | 1 + .../src/components/app-shell/app-sidebar.tsx | 21 +- .../src/components/app-shell/user-menu.tsx | 5 +- .../components/projects/agents/agent-card.tsx | 5 +- .../projects/agents/agent-detail.tsx | 59 ++++- .../projects/agents/conversations-layout.tsx | 5 +- .../projects/interactions/task-card.tsx | 42 ++-- .../task-detail/properties-panel.tsx | 2 + .../property-field/multi-user-editor.tsx | 19 +- .../task-detail/property-field/types.ts | 1 + .../property-field/user-editor.tsx | 13 +- .../interactions/task-detail/subtask-row.tsx | 24 +- .../projects/interactions/task-row.tsx | 44 ++-- .../interactions/view-settings-panel.tsx | 19 +- .../projects/settings/GeneralSettings.tsx | 53 ++++- .../src/components/shared/activity-pane.tsx | 7 +- .../src/components/shared/avatar-upload.tsx | 167 ++++++++++++++ .../src/components/shared/entity-avatar.tsx | 27 +++ .../shared/mention-suggestion-menus.tsx | 20 +- apps/web/src/i18n/locales/en/profile.json | 11 + apps/web/src/i18n/locales/en/projects.json | 22 ++ apps/web/src/i18n/locales/es/profile.json | 11 + apps/web/src/i18n/locales/es/projects.json | 22 ++ apps/web/src/i18n/locales/fr/profile.json | 11 + apps/web/src/i18n/locales/fr/projects.json | 22 ++ apps/web/src/i18n/locales/ja/profile.json | 11 + apps/web/src/i18n/locales/ja/projects.json | 22 ++ apps/web/src/i18n/locales/ko/profile.json | 11 + apps/web/src/i18n/locales/ko/projects.json | 22 ++ apps/web/src/i18n/locales/pt-BR/profile.json | 11 + apps/web/src/i18n/locales/pt-BR/projects.json | 22 ++ apps/web/src/i18n/locales/ru/profile.json | 11 + apps/web/src/i18n/locales/ru/projects.json | 22 ++ apps/web/src/i18n/locales/vi/profile.json | 11 + apps/web/src/i18n/locales/vi/projects.json | 22 ++ apps/web/src/i18n/locales/zh-CN/profile.json | 11 + apps/web/src/i18n/locales/zh-CN/projects.json | 22 ++ apps/web/src/lib/admin-api.ts | 2 + apps/web/src/lib/agent-api.ts | 1 + apps/web/src/lib/auth-api.ts | 2 + apps/web/src/lib/avatar-api.ts | 127 +++++++++++ apps/web/src/lib/doc-api.ts | 2 + apps/web/src/lib/interaction-api.ts | 2 + apps/web/src/lib/mention-api.ts | 6 +- apps/web/src/lib/project-api.ts | 21 ++ apps/web/src/lib/provider-logos.ts | 139 ++++++++++++ .../src/routes/_authenticated/home/index.tsx | 13 +- .../routes/_authenticated/profile/index.tsx | 58 +++-- .../projects/$projectId/team/index.tsx | 68 ++++-- services/api/go.mod | 6 +- services/api/go.sum | 6 + services/api/internal/bootstrap/app.go | 17 +- services/api/internal/domain/agent/entity.go | 12 +- services/api/internal/domain/agent/service.go | 16 ++ .../domain/attachment/avatar_service.go | 87 +++++++ .../api/internal/domain/attachment/errors.go | 12 + services/api/internal/domain/doc/activity.go | 14 +- .../api/internal/domain/project/entity.go | 11 +- .../api/internal/domain/project/member.go | 14 ++ .../api/internal/domain/project/service.go | 10 + services/api/internal/domain/task/activity.go | 14 +- services/api/internal/domain/user/entity.go | 13 +- services/api/internal/domain/user/service.go | 11 + services/api/internal/platform/storage/s3.go | 34 +++ .../api/internal/platform/storage/storage.go | 11 + .../repository/postgres/agent_repository.go | 43 ++-- .../postgres/document_repository.go | 12 +- .../repository/postgres/project_repository.go | 120 ++++++---- .../postgres/project_repository_test.go | 2 + .../postgres/task_activity_repository.go | 12 +- .../repository/postgres/user_repository.go | 12 +- .../postgres/user_repository_test.go | 2 + .../internal/service/agent/agent_service.go | 132 +++++++++++ .../service/attachment/avatar_service.go | 214 ++++++++++++++++++ .../service/attachment/avatar_service_test.go | 79 +++++++ .../service/project/cached_service.go | 30 +++ .../service/project/cached_service_test.go | 11 + .../service/project/project_service.go | 91 +++++++- .../api/internal/service/user/user_service.go | 83 +++++++ .../internal/transport/http/dto/agent_dto.go | 19 +- .../internal/transport/http/dto/avatar_dto.go | 11 + .../internal/transport/http/dto/doc_dto.go | 25 +- .../transport/http/dto/project_dto.go | 9 +- .../transport/http/dto/project_member_dto.go | 26 +++ .../internal/transport/http/dto/task_dto.go | 23 +- .../internal/transport/http/dto/user_dto.go | 7 +- .../transport/http/handler/agent_handler.go | 186 ++++++++++++++- .../http/handler/agent_handler_test.go | 19 ++ .../http/handler/document_handler.go | 28 ++- .../transport/http/handler/project_handler.go | 98 +++++++- .../http/handler/project_handler_test.go | 11 + .../http/handler/project_member_handler.go | 20 +- .../transport/http/handler/task_handler.go | 27 ++- .../transport/http/handler/user_handler.go | 113 ++++++++- .../http/handler/user_handler_test.go | 23 ++ .../transport/http/presenter/response.go | 6 + .../internal/transport/http/router/router.go | 27 +++ .../transport/http/router/router_test.go | 19 ++ .../api/migrations/000033_add_avatar_keys.sql | 24 ++ .../000034_add_project_avatar_keys.sql | 13 ++ .../api/test/integration/attachment_test.go | 198 ++++++++++++++++ 118 files changed, 3074 insertions(+), 278 deletions(-) create mode 100644 apps/web/public/provider-logos/anthropic.svg create mode 100644 apps/web/public/provider-logos/azure.svg create mode 100644 apps/web/public/provider-logos/bedrock.svg create mode 100644 apps/web/public/provider-logos/claude-code.svg create mode 100644 apps/web/public/provider-logos/codex.svg create mode 100644 apps/web/public/provider-logos/cohere.svg create mode 100644 apps/web/public/provider-logos/deepseek.svg create mode 100644 apps/web/public/provider-logos/gemini-cli.svg create mode 100644 apps/web/public/provider-logos/gemini.svg create mode 100644 apps/web/public/provider-logos/groq.svg create mode 100644 apps/web/public/provider-logos/meta_llama.svg create mode 100644 apps/web/public/provider-logos/mistral.svg create mode 100644 apps/web/public/provider-logos/ollama.svg create mode 100644 apps/web/public/provider-logos/openai.svg create mode 100644 apps/web/public/provider-logos/openrouter.svg create mode 100644 apps/web/public/provider-logos/perplexity.svg create mode 100644 apps/web/public/provider-logos/vertex_ai.svg create mode 100644 apps/web/public/provider-logos/xai.svg create mode 100644 apps/web/src/components/shared/avatar-upload.tsx create mode 100644 apps/web/src/components/shared/entity-avatar.tsx create mode 100644 apps/web/src/lib/avatar-api.ts create mode 100644 apps/web/src/lib/provider-logos.ts create mode 100644 services/api/internal/domain/attachment/avatar_service.go create mode 100644 services/api/internal/service/attachment/avatar_service.go create mode 100644 services/api/internal/service/attachment/avatar_service_test.go create mode 100644 services/api/internal/transport/http/dto/avatar_dto.go create mode 100644 services/api/migrations/000033_add_avatar_keys.sql create mode 100644 services/api/migrations/000034_add_project_avatar_keys.sql diff --git a/apps/web/public/provider-logos/anthropic.svg b/apps/web/public/provider-logos/anthropic.svg new file mode 100644 index 00000000..5f09f7f7 --- /dev/null +++ b/apps/web/public/provider-logos/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/apps/web/public/provider-logos/azure.svg b/apps/web/public/provider-logos/azure.svg new file mode 100644 index 00000000..09c243df --- /dev/null +++ b/apps/web/public/provider-logos/azure.svg @@ -0,0 +1 @@ +Azure \ No newline at end of file diff --git a/apps/web/public/provider-logos/bedrock.svg b/apps/web/public/provider-logos/bedrock.svg new file mode 100644 index 00000000..3e9ee146 --- /dev/null +++ b/apps/web/public/provider-logos/bedrock.svg @@ -0,0 +1 @@ +Bedrock \ No newline at end of file diff --git a/apps/web/public/provider-logos/claude-code.svg b/apps/web/public/provider-logos/claude-code.svg new file mode 100644 index 00000000..40cfedbd --- /dev/null +++ b/apps/web/public/provider-logos/claude-code.svg @@ -0,0 +1 @@ +Claude Code \ No newline at end of file diff --git a/apps/web/public/provider-logos/codex.svg b/apps/web/public/provider-logos/codex.svg new file mode 100644 index 00000000..0a384aec --- /dev/null +++ b/apps/web/public/provider-logos/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/apps/web/public/provider-logos/cohere.svg b/apps/web/public/provider-logos/cohere.svg new file mode 100644 index 00000000..bc3ff83f --- /dev/null +++ b/apps/web/public/provider-logos/cohere.svg @@ -0,0 +1 @@ +Cohere \ No newline at end of file diff --git a/apps/web/public/provider-logos/deepseek.svg b/apps/web/public/provider-logos/deepseek.svg new file mode 100644 index 00000000..64b054c8 --- /dev/null +++ b/apps/web/public/provider-logos/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/apps/web/public/provider-logos/gemini-cli.svg b/apps/web/public/provider-logos/gemini-cli.svg new file mode 100644 index 00000000..0601b6ae --- /dev/null +++ b/apps/web/public/provider-logos/gemini-cli.svg @@ -0,0 +1 @@ +Gemini CLI \ No newline at end of file diff --git a/apps/web/public/provider-logos/gemini.svg b/apps/web/public/provider-logos/gemini.svg new file mode 100644 index 00000000..508d95e6 --- /dev/null +++ b/apps/web/public/provider-logos/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/apps/web/public/provider-logos/groq.svg b/apps/web/public/provider-logos/groq.svg new file mode 100644 index 00000000..a6f245c5 --- /dev/null +++ b/apps/web/public/provider-logos/groq.svg @@ -0,0 +1 @@ +Groq \ No newline at end of file diff --git a/apps/web/public/provider-logos/meta_llama.svg b/apps/web/public/provider-logos/meta_llama.svg new file mode 100644 index 00000000..d2c6bf98 --- /dev/null +++ b/apps/web/public/provider-logos/meta_llama.svg @@ -0,0 +1 @@ +MetaAI \ No newline at end of file diff --git a/apps/web/public/provider-logos/mistral.svg b/apps/web/public/provider-logos/mistral.svg new file mode 100644 index 00000000..e36a8ce3 --- /dev/null +++ b/apps/web/public/provider-logos/mistral.svg @@ -0,0 +1 @@ +Mistral \ No newline at end of file diff --git a/apps/web/public/provider-logos/ollama.svg b/apps/web/public/provider-logos/ollama.svg new file mode 100644 index 00000000..e7e4e5da --- /dev/null +++ b/apps/web/public/provider-logos/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/apps/web/public/provider-logos/openai.svg b/apps/web/public/provider-logos/openai.svg new file mode 100644 index 00000000..0e3364a0 --- /dev/null +++ b/apps/web/public/provider-logos/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/apps/web/public/provider-logos/openrouter.svg b/apps/web/public/provider-logos/openrouter.svg new file mode 100644 index 00000000..a5b59c8f --- /dev/null +++ b/apps/web/public/provider-logos/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/apps/web/public/provider-logos/perplexity.svg b/apps/web/public/provider-logos/perplexity.svg new file mode 100644 index 00000000..86ea5853 --- /dev/null +++ b/apps/web/public/provider-logos/perplexity.svg @@ -0,0 +1 @@ +Perplexity \ No newline at end of file diff --git a/apps/web/public/provider-logos/vertex_ai.svg b/apps/web/public/provider-logos/vertex_ai.svg new file mode 100644 index 00000000..cf030ef5 --- /dev/null +++ b/apps/web/public/provider-logos/vertex_ai.svg @@ -0,0 +1 @@ +VertexAI \ No newline at end of file diff --git a/apps/web/public/provider-logos/xai.svg b/apps/web/public/provider-logos/xai.svg new file mode 100644 index 00000000..b350ce48 --- /dev/null +++ b/apps/web/public/provider-logos/xai.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/apps/web/src/components/app-shell/app-sidebar.tsx b/apps/web/src/components/app-shell/app-sidebar.tsx index 07e16927..2aef3393 100644 --- a/apps/web/src/components/app-shell/app-sidebar.tsx +++ b/apps/web/src/components/app-shell/app-sidebar.tsx @@ -44,6 +44,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; import { Badge } from "@/components/ui/badge"; import { DropdownMenu, @@ -93,7 +94,11 @@ import type { PluginNavRegistration } from "@/lib/plugin-api"; import { ExtensionPoint } from "@/lib/plugins/extension-point"; import { resolvePluginIcon } from "@/lib/plugins/icon-resolver"; import { usePluginRegistry } from "@/lib/plugins/registry"; -import { projectQueryOptions, projectsQueryOptions } from "@/lib/project-api"; +import { + getProjectInitials, + projectQueryOptions, + projectsQueryOptions, +} from "@/lib/project-api"; import { cn } from "@/lib/utils"; import { UserMenu } from "./user-menu"; @@ -729,7 +734,7 @@ function ProjectSwitcher({ const projects = projectsResult?.items ?? []; const label = currentProject?.name ?? t("projectSwitcher.projects"); const initials = currentProject?.name - ? currentProject.name.slice(0, 2).toUpperCase() + ? getProjectInitials(currentProject.name) : null; const { data: user } = useQuery(currentUserOptionalQueryOptions); @@ -738,7 +743,9 @@ function ProjectSwitcher({ return (
- {initials ?? } + + {initials ?? } +
{label}
@@ -756,7 +763,9 @@ function ProjectSwitcher({ )} >
- {initials ?? } + + {initials ?? } +
{label}
- {p.name.slice(0, 2).toUpperCase()} + + {getProjectInitials(p.name)} +
{p.name} {p.id === currentProjectId && ( diff --git a/apps/web/src/components/app-shell/user-menu.tsx b/apps/web/src/components/app-shell/user-menu.tsx index 5b81c0ad..267f43f6 100644 --- a/apps/web/src/components/app-shell/user-menu.tsx +++ b/apps/web/src/components/app-shell/user-menu.tsx @@ -12,7 +12,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { LocaleRadioGroup } from "@/components/LocaleRadioGroup"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, @@ -105,6 +105,9 @@ export function UserMenu() { } > + {user.avatar_thumb_url ? ( + + ) : null} {initials} diff --git a/apps/web/src/components/projects/agents/agent-card.tsx b/apps/web/src/components/projects/agents/agent-card.tsx index 96825256..e460d4bf 100644 --- a/apps/web/src/components/projects/agents/agent-card.tsx +++ b/apps/web/src/components/projects/agents/agent-card.tsx @@ -4,7 +4,7 @@ import { Loader2, MoreHorizontal, Settings, Trash2, Zap } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -30,6 +30,7 @@ import { globalAcpBridgeStatusQueryOptions, globalAgentsQueryOptions, } from "@/lib/agent-api"; +import { resolveAgentAvatarUrl } from "@/lib/provider-logos"; import { cn } from "@/lib/utils"; // Shared between the project Agents page @@ -91,6 +92,7 @@ export function AgentCard({ .join("") .toUpperCase() .slice(0, 2); + const avatarUrl = resolveAgentAvatarUrl(agent); const detailHref = projectId ? `/projects/${projectId}/agents/${agent.id}` @@ -112,6 +114,7 @@ export function AgentCard({
+ {avatarUrl ? : null} {initials} diff --git a/apps/web/src/components/projects/agents/agent-detail.tsx b/apps/web/src/components/projects/agents/agent-detail.tsx index 7581142c..f65d3773 100644 --- a/apps/web/src/components/projects/agents/agent-detail.tsx +++ b/apps/web/src/components/projects/agents/agent-detail.tsx @@ -14,7 +14,7 @@ import { } from "lucide-react"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { AvatarUpload } from "@/components/shared/avatar-upload"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -74,6 +74,7 @@ import { updateMCPServer, updateSkill, } from "@/lib/agent-api"; +import { resolveAgentAvatarUrl } from "@/lib/provider-logos"; import { splitShellCommand } from "@/lib/shell-command"; import { AcpBridgeSetup } from "./acp-bridge-setup"; import { AgentActivityTab } from "./agent-activity-tab"; @@ -1337,6 +1338,7 @@ export function AgentDetailView({ agentId: string; }) { const { t } = useTranslation("projects"); + const qc = useQueryClient(); // Both permission hooks are always called (never conditionally, per the // rules of hooks) — useProjectPermissions no-ops its own query when @@ -1438,11 +1440,56 @@ export function AgentDetailView({ {/* Agent header */}
- - - {initials} - - + { + qc.setQueryData( + (projectId + ? agentQueryOptions(projectId, agent.id) + : globalAgentQueryOptions(agent.id) + ).queryKey, + (old) => (old ? { ...old, ...result } : old), + ); + // The single-agent cache above only fixes this page. Every + // other place that shows this agent's avatar — the agent + // list/cards, the chat agent picker, the conversations list, + // and (via project members) the team page and task + // assignee/reporter chips — reads from separate query caches + // that won't pick up the change until invalidated. A global + // agent can also belong to several projects at once, so the + // members invalidation matches every project's members query, + // not just this one. + qc.invalidateQueries({ + queryKey: projectId + ? ["projects", projectId, "agents"] + : ["global-agents"], + }); + qc.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === "projects" && + query.queryKey[2] === "members", + }); + }} + />

{agent.name}

diff --git a/apps/web/src/components/projects/agents/conversations-layout.tsx b/apps/web/src/components/projects/agents/conversations-layout.tsx index 85775ca4..520ddd49 100644 --- a/apps/web/src/components/projects/agents/conversations-layout.tsx +++ b/apps/web/src/components/projects/agents/conversations-layout.tsx @@ -3,7 +3,7 @@ import { Link, Outlet, useParams } from "@tanstack/react-router"; import { Clock, MessageSquare, Plus, Zap } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -20,6 +20,7 @@ import { conversationsQueryOptions, globalConversationsQueryOptions, } from "@/lib/agent-api"; +import { resolveAgentAvatarUrl } from "@/lib/provider-logos"; import { cn } from "@/lib/utils"; import { ConversationFilters } from "./conversation-filters"; @@ -71,6 +72,7 @@ function ConversationListItem({ .join("") .toUpperCase() .slice(0, 2); + const avatarUrl = agent ? resolveAgentAvatarUrl(agent) : undefined; const href = projectId ? `/projects/${projectId}/conversations/${conv.id}` @@ -88,6 +90,7 @@ function ConversationListItem({ >
+ {avatarUrl ? : null} {initials} diff --git a/apps/web/src/components/projects/interactions/task-card.tsx b/apps/web/src/components/projects/interactions/task-card.tsx index ee8d4fb5..62dc2881 100644 --- a/apps/web/src/components/projects/interactions/task-card.tsx +++ b/apps/web/src/components/projects/interactions/task-card.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { getTaskTypeIconComponent } from "@/components/projects/task-types/task-type-icons"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; import { DropdownMenu, DropdownMenuContent, @@ -31,6 +32,7 @@ import { type TaskStatus, type TaskType, } from "@/lib/project-api"; +import { resolveMemberAvatarUrl } from "@/lib/provider-logos"; import { useHoveredTaskStore } from "@/lib/shortcuts/hovered-task-store"; import { cn } from "@/lib/utils"; @@ -149,11 +151,15 @@ export function TaskCard({ key={id} className="flex size-5 items-center justify-center rounded-full bg-linear-to-br from-primary/20 to-primary/15 text-primary text-xs font-bold ring-2 ring-card" > - {m ? ( - (m.full_name || m.username).slice(0, 1).toUpperCase() - ) : ( - - )} + + {m ? ( + (m.full_name || m.username).slice(0, 1).toUpperCase() + ) : ( + + )} +
); }) @@ -217,7 +223,11 @@ export function TaskCard({ }} >
- {(m.full_name || m.username).slice(0, 1).toUpperCase()} + + {(m.full_name || m.username).slice(0, 1).toUpperCase()} +
{m.full_name || m.username} @@ -476,13 +486,19 @@ export function TaskCard({ } className="flex size-5 items-center justify-center rounded-full bg-linear-to-br from-muted/80 to-muted/40 text-muted-foreground text-xs font-bold ring-1 ring-border/25" > - {reporter ? ( - (reporter.full_name || reporter.username) - .slice(0, 1) - .toUpperCase() - ) : ( - - )} + + {reporter ? ( + (reporter.full_name || reporter.username) + .slice(0, 1) + .toUpperCase() + ) : ( + + )} +
); } diff --git a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx index 9ce44da5..1626006a 100644 --- a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx @@ -26,6 +26,7 @@ import { type TaskStatus, type TaskType, } from "@/lib/project-api"; +import { resolveMemberAvatarUrl } from "@/lib/provider-logos"; import { getTaskTypeIconComponent } from "../../task-types/task-type-icons"; import type { PriorityMeta } from "../priority"; import { @@ -92,6 +93,7 @@ function toUserOption(m: ProjectMember): UserOption { value: m.id, label: m.full_name || m.username, initials: (m.full_name || m.username).slice(0, 1).toUpperCase(), + avatarUrl: resolveMemberAvatarUrl(m), }; } diff --git a/apps/web/src/components/projects/interactions/task-detail/property-field/multi-user-editor.tsx b/apps/web/src/components/projects/interactions/task-detail/property-field/multi-user-editor.tsx index a3d3687b..217057da 100644 --- a/apps/web/src/components/projects/interactions/task-detail/property-field/multi-user-editor.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/property-field/multi-user-editor.tsx @@ -1,13 +1,22 @@ import { Check } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; import { FieldValue } from "../primitives"; import { ChipField } from "./chip-field"; import type { UserOption } from "./types"; -function UserAvatar({ initials }: { initials: string }) { +function UserAvatar({ + initials, + avatarUrl, +}: { + initials: string; + avatarUrl?: string | null; +}) { return (
- {initials} + + {initials} +
); } @@ -27,7 +36,7 @@ function UserListButton({ className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm hover:bg-muted/60 transition-colors duration-100" onClick={onClick} > - + {user.label} {isSelected && } @@ -65,7 +74,7 @@ export function MultiUserEditor({ key={u.value} className="inline-flex items-center gap-1.5 rounded-full border border-border/30 bg-muted/30 px-2.5 py-0.5 text-xs font-semibold text-muted-foreground" > - + {u.label} ))} @@ -79,7 +88,7 @@ export function MultiUserEditor({ key: u.value, label: ( - + {u.label} ), diff --git a/apps/web/src/components/projects/interactions/task-detail/property-field/types.ts b/apps/web/src/components/projects/interactions/task-detail/property-field/types.ts index 3f694de5..fc801a8a 100644 --- a/apps/web/src/components/projects/interactions/task-detail/property-field/types.ts +++ b/apps/web/src/components/projects/interactions/task-detail/property-field/types.ts @@ -26,6 +26,7 @@ export interface UserOption { value: string; label: string; initials: string; + avatarUrl?: string | null; } export interface PropertyFieldProps { diff --git a/apps/web/src/components/projects/interactions/task-detail/property-field/user-editor.tsx b/apps/web/src/components/projects/interactions/task-detail/property-field/user-editor.tsx index 810b731f..d6e18b87 100644 --- a/apps/web/src/components/projects/interactions/task-detail/property-field/user-editor.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/property-field/user-editor.tsx @@ -1,5 +1,6 @@ import { Check, User } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; import { Popover, PopoverContent, @@ -33,7 +34,9 @@ export function UserEditor({ return (
- {userValue.initials} + + {userValue.initials} +
{userValue.label} @@ -55,7 +58,9 @@ export function UserEditor({ {userValue ? ( <>
- {userValue.initials} + + {userValue.initials} +
{userValue.label} @@ -93,7 +98,9 @@ export function UserEditor({ onClick={() => onChange?.(u.value)} >
- {u.initials} + + {u.initials} +
{u.label} {u.value === userValue?.value && ( diff --git a/apps/web/src/components/projects/interactions/task-detail/subtask-row.tsx b/apps/web/src/components/projects/interactions/task-detail/subtask-row.tsx index ff5c7745..14ca15af 100644 --- a/apps/web/src/components/projects/interactions/task-detail/subtask-row.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/subtask-row.tsx @@ -1,5 +1,6 @@ import { Check, User } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; import { DropdownMenu, DropdownMenuContent, @@ -13,6 +14,7 @@ import { } from "@/components/ui/popover"; import type { Task } from "@/lib/interaction-api"; import type { ProjectMember, TaskStatus, TaskType } from "@/lib/project-api"; +import { resolveMemberAvatarUrl } from "@/lib/provider-logos"; import { cn } from "@/lib/utils"; import { getPriority, @@ -122,11 +124,15 @@ export function SubtaskRow({ key={id} className="flex size-5.5 items-center justify-center rounded-full bg-linear-to-br from-primary/20 to-primary/10 text-primary text-xs font-bold ring-2 ring-card" > - {m ? ( - (m.full_name || m.username).slice(0, 1).toUpperCase() - ) : ( - - )} + + {m ? ( + (m.full_name || m.username).slice(0, 1).toUpperCase() + ) : ( + + )} +
); }) @@ -185,7 +191,13 @@ export function SubtaskRow({ }} >
- {(m.full_name || m.username).slice(0, 1).toUpperCase()} + + {(m.full_name || m.username) + .slice(0, 1) + .toUpperCase()} +
{m.full_name || m.username} diff --git a/apps/web/src/components/projects/interactions/task-row.tsx b/apps/web/src/components/projects/interactions/task-row.tsx index ae6ed9af..dc5baca3 100644 --- a/apps/web/src/components/projects/interactions/task-row.tsx +++ b/apps/web/src/components/projects/interactions/task-row.tsx @@ -11,6 +11,7 @@ import { import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; import { DropdownMenu, DropdownMenuContent, @@ -31,6 +32,7 @@ import { type TaskStatus, type TaskType, } from "@/lib/project-api"; +import { resolveMemberAvatarUrl } from "@/lib/provider-logos"; import { useHoveredTaskStore } from "@/lib/shortcuts/hovered-task-store"; import { cn } from "@/lib/utils"; @@ -419,11 +421,15 @@ export function TaskRow({ key={id} className="flex size-6 items-center justify-center rounded-full bg-linear-to-br from-primary/20 to-primary/10 text-primary text-xs font-bold ring-2 ring-card" > - {m ? ( - (m.full_name || m.username).slice(0, 1).toUpperCase() - ) : ( - - )} + + {m ? ( + (m.full_name || m.username).slice(0, 1).toUpperCase() + ) : ( + + )} +
); }) @@ -491,7 +497,13 @@ export function TaskRow({ }} >
- {(m.full_name || m.username).slice(0, 1).toUpperCase()} + + {(m.full_name || m.username) + .slice(0, 1) + .toUpperCase()} +
{m.full_name || m.username} @@ -529,13 +541,19 @@ export function TaskRow({ )} >
- {reporter ? ( - (reporter.full_name || reporter.username) - .slice(0, 1) - .toUpperCase() - ) : ( - - )} + + {reporter ? ( + (reporter.full_name || reporter.username) + .slice(0, 1) + .toUpperCase() + ) : ( + + )} +
); diff --git a/apps/web/src/components/projects/interactions/view-settings-panel.tsx b/apps/web/src/components/projects/interactions/view-settings-panel.tsx index 6624fbdf..532b49e3 100644 --- a/apps/web/src/components/projects/interactions/view-settings-panel.tsx +++ b/apps/web/src/components/projects/interactions/view-settings-panel.tsx @@ -9,6 +9,7 @@ import { import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { getTaskTypeIconComponent } from "@/components/projects/task-types/task-type-icons"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; import { Popover, PopoverContent, @@ -27,6 +28,7 @@ import { import { type CustomFieldDefinition, customFieldsQueryOptions, + type ProjectMember, projectMembersQueryOptions, STATUS_CATEGORIES, STATUS_CATEGORY_LABELS, @@ -36,6 +38,7 @@ import { taskStatusesQueryOptions, taskTypesQueryOptions, } from "@/lib/project-api"; +import { resolveMemberAvatarUrl } from "@/lib/provider-logos"; import { cn } from "@/lib/utils"; import { PRIORITY_LEVELS } from "./priority"; import { ChipField } from "./task-detail/property-field/chip-field"; @@ -495,7 +498,17 @@ function AssigneeFilterSection({ selectedIds, onChange, }: { - members: { id: string; full_name: string; username: string }[]; + members: Pick< + ProjectMember, + | "id" + | "full_name" + | "username" + | "avatar_thumb_url" + | "member_type" + | "agent_type" + | "agent_llm_provider" + | "agent_acp_provider" + >[]; selectedIds: string[]; onChange: (ids: string[]) => void; }) { @@ -540,7 +553,9 @@ function AssigneeFilterSection({ onChange={() => toggle(m.id)} icon={
- {display.slice(0, 1).toUpperCase()} + + {display.slice(0, 1).toUpperCase()} +
} /> diff --git a/apps/web/src/components/projects/settings/GeneralSettings.tsx b/apps/web/src/components/projects/settings/GeneralSettings.tsx index 2222180d..1104243d 100644 --- a/apps/web/src/components/projects/settings/GeneralSettings.tsx +++ b/apps/web/src/components/projects/settings/GeneralSettings.tsx @@ -2,13 +2,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Globe, Loader2, Lock } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { AvatarUpload } from "@/components/shared/avatar-upload"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ApiErrorCode, getApiErrorCode } from "@/lib/api-error"; -import { projectQueryOptions, updateProject } from "@/lib/project-api"; +import { + getProjectInitials, + projectQueryOptions, + updateProject, +} from "@/lib/project-api"; export function GeneralSettings({ projectId, @@ -78,11 +84,56 @@ export function GeneralSettings({ prefix.trim() !== (project?.task_id_prefix ?? "") || isPublic !== (project?.is_public ?? false); + const initials = getProjectInitials(project?.name ?? ""); + return (

{t("settings.general.title")}

+ + {/* Identity header — avatar next to the project's name/prefix, same + layout as the profile page's own avatar setting. */} +
+ { + queryClient.setQueryData( + projectQueryOptions(projectId).queryKey, + (old) => (old ? { ...old, ...result } : old), + ); + // Other places reading the project list (sidebar switcher, + // home page cards) hold a separate cache entry that + // setQueryData above doesn't touch. + queryClient.invalidateQueries({ queryKey: ["projects"] }); + }} + /> +
+

{project?.name}

+ {project?.task_id_prefix ? ( +

+ {project.task_id_prefix} +

+ ) : null} +
+
+ + +
{isComment ? ( diff --git a/apps/web/src/components/shared/avatar-upload.tsx b/apps/web/src/components/shared/avatar-upload.tsx new file mode 100644 index 00000000..46d14486 --- /dev/null +++ b/apps/web/src/components/shared/avatar-upload.tsx @@ -0,0 +1,167 @@ +import { Camera, Loader2, X } from "lucide-react"; +import { type ReactNode, useRef, useState } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + ACCEPTED_AVATAR_CONTENT_TYPES, + type AvatarResult, + removeAvatar, + uploadAvatar, + validateAvatarFile, +} from "@/lib/avatar-api"; +import { cn } from "@/lib/utils"; + +export interface AvatarUploadLabels { + change: string; + remove: string; + uploading: string; + invalidType: string; + tooLarge: string; + uploadFailed: string; + removeFailed: string; +} + +interface AvatarUploadProps { + /** Owner path — "/users/me", "/projects/{id}/agents/{id}", or "/admin/agents/{id}". */ + basePath: string; + avatarUrl?: string | null; + /** Shown when there's no avatar (initials or an icon). */ + fallback: ReactNode; + onChange: (result: AvatarResult) => void; + labels: AvatarUploadLabels; + /** Sizing/shape for the avatar itself, e.g. "size-14 rounded-xl". */ + className?: string; + fallbackClassName?: string; + disabled?: boolean; + /** Whether the remove button can show at all — pass `false` when + * `avatarUrl` is a placeholder (e.g. a provider-logo default) rather than + * a real upload, since there's nothing to remove. Defaults to `true`. */ + canRemove?: boolean; +} + +export function AvatarUpload({ + basePath, + avatarUrl, + fallback, + onChange, + labels, + className, + fallbackClassName, + disabled, + canRemove = true, +}: AvatarUploadProps) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + + const validationError = validateAvatarFile(file); + if (validationError) { + setError( + validationError === "tooLarge" ? labels.tooLarge : labels.invalidType, + ); + return; + } + + setError(null); + setUploading(true); + try { + const result = await uploadAvatar(basePath, file); + onChange(result); + } catch { + setError(labels.uploadFailed); + } finally { + setUploading(false); + } + }; + + const handleRemove = async () => { + setError(null); + setUploading(true); + try { + const result = await removeAvatar(basePath); + onChange(result); + } catch { + setError(labels.removeFailed); + } finally { + setUploading(false); + } + }; + + return ( +
+ {/* The shape (e.g. "rounded-xl") lives here, on the outer frame, so the + photo, the initials fallback, and the hover overlay can all inherit + the exact same radius via `rounded-[inherit]` instead of each + needing their own copy of it. */} +
+ + {/* Always mounted (never conditionally omitted): base-ui's Avatar + tracks image-load status internally and only shows Fallback + once it observes a missing/failed src — omitting this element + from the tree instead leaves that internal state stuck at + whatever it last was, so removing an avatar left the whole + thing blank (no photo, no fallback) until a full reload. */} + + + {fallback} + + + + + + {avatarUrl && canRemove && !uploading ? ( + + ) : null} + + +
+ {error ?

{error}

: null} +
+ ); +} diff --git a/apps/web/src/components/shared/entity-avatar.tsx b/apps/web/src/components/shared/entity-avatar.tsx new file mode 100644 index 00000000..93c832b7 --- /dev/null +++ b/apps/web/src/components/shared/entity-avatar.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +/** + * Display-only content for the many places that hand-roll their own sized + * initials `
` instead of the `Avatar` primitive (task assignee stacks, + * activity feed, team list, etc.) — drops into that existing wrapper without + * touching its size/ring/gradient classes. Renders an image when avatarUrl + * is set, otherwise the given fallback (initials text or an icon). + */ +export function EntityAvatarContent({ + avatarUrl, + children, +}: { + avatarUrl?: string | null; + children: ReactNode; +}) { + if (avatarUrl) { + return ( + + ); + } + return <>{children}; +} diff --git a/apps/web/src/components/shared/mention-suggestion-menus.tsx b/apps/web/src/components/shared/mention-suggestion-menus.tsx index 2c97b31e..b8d52201 100644 --- a/apps/web/src/components/shared/mention-suggestion-menus.tsx +++ b/apps/web/src/components/shared/mention-suggestion-menus.tsx @@ -4,6 +4,7 @@ import { type DefaultReactSuggestionItem, SuggestionMenuController, } from "@blocknote/react"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; import { useDebouncedAsyncCallback } from "@/hooks/use-debounced-callback"; import { searchMentionableTasks } from "@/lib/mention-api"; import type { customSchema } from "./blocknote-schema"; @@ -30,7 +31,7 @@ interface MentionSuggestionMenuProps { id: string; name: string; username: string; - avatar?: string | null | undefined; + avatarThumbUrl?: string | null | undefined; }>; /** Needed to search tasks live as the user types after "#" — the project's * task list has no fixed upper bound, so it's queried on demand instead @@ -54,6 +55,16 @@ export function MentionSuggestionMenus({ return teamMembers.map((member) => ({ title: member.name, subtext: `@${member.username}`, + // Resolved fresh from the live teamMembers list every time the + // dropdown opens — safe, unlike the mention chip's own props (see + // the avatar: "" comment below), since this is never persisted. + icon: ( +
+ + {member.name.slice(0, 1).toUpperCase()} + +
+ ), onItemClick: () => { editor.insertInlineContent([ { @@ -61,7 +72,12 @@ export function MentionSuggestionMenus({ props: { id: member.id, name: member.name, - avatar: member.avatar ?? "", + // Never populated with a real URL: mention props are baked + // into permanently-stored document content, and a presigned + // avatar URL would expire long before the mention does. The + // live suggestion dropdown (below) shows the real avatar + // instead, since it's resolved fresh every time it opens. + avatar: "", }, }, " ", diff --git a/apps/web/src/i18n/locales/en/profile.json b/apps/web/src/i18n/locales/en/profile.json index 0f54340a..b8e987fb 100644 --- a/apps/web/src/i18n/locales/en/profile.json +++ b/apps/web/src/i18n/locales/en/profile.json @@ -18,6 +18,17 @@ "errors": { "updateFailed": "Failed to update profile. Please try again." }, + "avatar": { + "change": "Change avatar", + "remove": "Remove avatar", + "uploading": "Uploading…", + "errors": { + "invalidType": "Please choose a PNG, JPEG, WEBP, or GIF image.", + "tooLarge": "Image must be 5 MB or smaller.", + "uploadFailed": "Failed to upload avatar. Please try again.", + "removeFailed": "Failed to remove avatar. Please try again." + } + }, "joinedOn": "Joined {{date}}", "changePassword": { "title": "Change Password", diff --git a/apps/web/src/i18n/locales/en/projects.json b/apps/web/src/i18n/locales/en/projects.json index 22f150c6..cc8f6526 100644 --- a/apps/web/src/i18n/locales/en/projects.json +++ b/apps/web/src/i18n/locales/en/projects.json @@ -138,6 +138,17 @@ "envVars": "Environment", "activity": "Activity" }, + "avatar": { + "change": "Change avatar", + "remove": "Remove avatar", + "uploading": "Uploading…", + "errors": { + "invalidType": "Please choose a PNG, JPEG, WEBP, or GIF image.", + "tooLarge": "Image must be 5 MB or smaller.", + "uploadFailed": "Failed to upload avatar. Please try again.", + "removeFailed": "Failed to remove avatar. Please try again." + } + }, "overview": { "nameLabel": "Name", "llmConfiguration": "LLM Configuration", @@ -457,6 +468,17 @@ "settings": { "general": { "title": "General", + "avatar": { + "change": "Change avatar", + "remove": "Remove avatar", + "uploading": "Uploading…", + "errors": { + "invalidType": "Please choose a PNG, JPEG, WEBP, or GIF image.", + "tooLarge": "Image must be 5 MB or smaller.", + "uploadFailed": "Failed to upload avatar. Please try again.", + "removeFailed": "Failed to remove avatar. Please try again." + } + }, "projectNameLabel": "Project name", "projectNamePlaceholder": "My awesome project", "taskIdPrefixLabel": "Task ID prefix", diff --git a/apps/web/src/i18n/locales/es/profile.json b/apps/web/src/i18n/locales/es/profile.json index b2ad8fe8..00c6e5b3 100644 --- a/apps/web/src/i18n/locales/es/profile.json +++ b/apps/web/src/i18n/locales/es/profile.json @@ -18,6 +18,17 @@ "errors": { "updateFailed": "No se pudo actualizar el perfil. Inténtalo de nuevo." }, + "avatar": { + "change": "Cambiar avatar", + "remove": "Eliminar avatar", + "uploading": "Subiendo…", + "errors": { + "invalidType": "Elige una imagen PNG, JPEG, WEBP o GIF.", + "tooLarge": "La imagen debe pesar 5 MB o menos.", + "uploadFailed": "No se pudo subir el avatar. Inténtalo de nuevo.", + "removeFailed": "No se pudo eliminar el avatar. Inténtalo de nuevo." + } + }, "joinedOn": "Se unió el {{date}}", "changePassword": { "title": "Cambiar contraseña", diff --git a/apps/web/src/i18n/locales/es/projects.json b/apps/web/src/i18n/locales/es/projects.json index 85b178ff..1bc70c3b 100644 --- a/apps/web/src/i18n/locales/es/projects.json +++ b/apps/web/src/i18n/locales/es/projects.json @@ -138,6 +138,17 @@ "envVars": "Entorno", "activity": "Actividad" }, + "avatar": { + "change": "Cambiar avatar", + "remove": "Eliminar avatar", + "uploading": "Subiendo…", + "errors": { + "invalidType": "Elige una imagen PNG, JPEG, WEBP o GIF.", + "tooLarge": "La imagen debe pesar 5 MB o menos.", + "uploadFailed": "No se pudo subir el avatar. Inténtalo de nuevo.", + "removeFailed": "No se pudo eliminar el avatar. Inténtalo de nuevo." + } + }, "overview": { "nameLabel": "Nombre", "llmConfiguration": "Configuración del LLM", @@ -457,6 +468,17 @@ "settings": { "general": { "title": "General", + "avatar": { + "change": "Cambiar avatar", + "remove": "Eliminar avatar", + "uploading": "Subiendo…", + "errors": { + "invalidType": "Elige una imagen PNG, JPEG, WEBP o GIF.", + "tooLarge": "La imagen debe pesar 5 MB o menos.", + "uploadFailed": "No se pudo subir el avatar. Inténtalo de nuevo.", + "removeFailed": "No se pudo eliminar el avatar. Inténtalo de nuevo." + } + }, "projectNameLabel": "Nombre del proyecto", "projectNamePlaceholder": "Mi proyecto increíble", "taskIdPrefixLabel": "Prefijo de ID de tarea", diff --git a/apps/web/src/i18n/locales/fr/profile.json b/apps/web/src/i18n/locales/fr/profile.json index c586d689..2cfa4240 100644 --- a/apps/web/src/i18n/locales/fr/profile.json +++ b/apps/web/src/i18n/locales/fr/profile.json @@ -18,6 +18,17 @@ "errors": { "updateFailed": "Échec de la mise à jour du profil. Veuillez réessayer." }, + "avatar": { + "change": "Changer l’avatar", + "remove": "Supprimer l’avatar", + "uploading": "Envoi en cours…", + "errors": { + "invalidType": "Veuillez choisir une image PNG, JPEG, WEBP ou GIF.", + "tooLarge": "L’image doit faire 5 Mo maximum.", + "uploadFailed": "Échec de l’envoi de l’avatar. Veuillez réessayer.", + "removeFailed": "Échec de la suppression de l’avatar. Veuillez réessayer." + } + }, "joinedOn": "Inscrit le {{date}}", "changePassword": { "title": "Changer le mot de passe", diff --git a/apps/web/src/i18n/locales/fr/projects.json b/apps/web/src/i18n/locales/fr/projects.json index 9fdf44ed..e10f4ade 100644 --- a/apps/web/src/i18n/locales/fr/projects.json +++ b/apps/web/src/i18n/locales/fr/projects.json @@ -138,6 +138,17 @@ "envVars": "Environnement", "activity": "Activité" }, + "avatar": { + "change": "Changer l’avatar", + "remove": "Supprimer l’avatar", + "uploading": "Envoi en cours…", + "errors": { + "invalidType": "Veuillez choisir une image PNG, JPEG, WEBP ou GIF.", + "tooLarge": "L’image doit faire 5 Mo maximum.", + "uploadFailed": "Échec de l’envoi de l’avatar. Veuillez réessayer.", + "removeFailed": "Échec de la suppression de l’avatar. Veuillez réessayer." + } + }, "overview": { "nameLabel": "Nom", "llmConfiguration": "Configuration du LLM", @@ -457,6 +468,17 @@ "settings": { "general": { "title": "Général", + "avatar": { + "change": "Changer l’avatar", + "remove": "Supprimer l’avatar", + "uploading": "Envoi en cours…", + "errors": { + "invalidType": "Veuillez choisir une image PNG, JPEG, WEBP ou GIF.", + "tooLarge": "L’image doit faire 5 Mo maximum.", + "uploadFailed": "Échec de l’envoi de l’avatar. Veuillez réessayer.", + "removeFailed": "Échec de la suppression de l’avatar. Veuillez réessayer." + } + }, "projectNameLabel": "Nom du projet", "projectNamePlaceholder": "Mon super projet", "taskIdPrefixLabel": "Préfixe d'ID de tâche", diff --git a/apps/web/src/i18n/locales/ja/profile.json b/apps/web/src/i18n/locales/ja/profile.json index ab15df34..2f519549 100644 --- a/apps/web/src/i18n/locales/ja/profile.json +++ b/apps/web/src/i18n/locales/ja/profile.json @@ -18,6 +18,17 @@ "errors": { "updateFailed": "プロフィールの更新に失敗しました。もう一度お試しください。" }, + "avatar": { + "change": "アバターを変更", + "remove": "アバターを削除", + "uploading": "アップロード中…", + "errors": { + "invalidType": "PNG、JPEG、WEBP、GIF形式の画像を選択してください。", + "tooLarge": "画像は5MB以下にしてください。", + "uploadFailed": "アバターのアップロードに失敗しました。もう一度お試しください。", + "removeFailed": "アバターの削除に失敗しました。もう一度お試しください。" + } + }, "joinedOn": "{{date}}に参加", "changePassword": { "title": "パスワードを変更", diff --git a/apps/web/src/i18n/locales/ja/projects.json b/apps/web/src/i18n/locales/ja/projects.json index 4ab1c38b..5034d58a 100644 --- a/apps/web/src/i18n/locales/ja/projects.json +++ b/apps/web/src/i18n/locales/ja/projects.json @@ -138,6 +138,17 @@ "envVars": "環境変数", "activity": "アクティビティ" }, + "avatar": { + "change": "アバターを変更", + "remove": "アバターを削除", + "uploading": "アップロード中…", + "errors": { + "invalidType": "PNG、JPEG、WEBP、GIF形式の画像を選択してください。", + "tooLarge": "画像は5MB以下にしてください。", + "uploadFailed": "アバターのアップロードに失敗しました。もう一度お試しください。", + "removeFailed": "アバターの削除に失敗しました。もう一度お試しください。" + } + }, "overview": { "nameLabel": "名前", "llmConfiguration": "LLM設定", @@ -457,6 +468,17 @@ "settings": { "general": { "title": "一般", + "avatar": { + "change": "アバターを変更", + "remove": "アバターを削除", + "uploading": "アップロード中…", + "errors": { + "invalidType": "PNG、JPEG、WEBP、GIF形式の画像を選択してください。", + "tooLarge": "画像は5MB以下にしてください。", + "uploadFailed": "アバターのアップロードに失敗しました。もう一度お試しください。", + "removeFailed": "アバターの削除に失敗しました。もう一度お試しください。" + } + }, "projectNameLabel": "プロジェクト名", "projectNamePlaceholder": "My awesome project", "taskIdPrefixLabel": "タスクIDプレフィックス", diff --git a/apps/web/src/i18n/locales/ko/profile.json b/apps/web/src/i18n/locales/ko/profile.json index 371a2174..ca0a3022 100644 --- a/apps/web/src/i18n/locales/ko/profile.json +++ b/apps/web/src/i18n/locales/ko/profile.json @@ -18,6 +18,17 @@ "errors": { "updateFailed": "프로필 업데이트에 실패했습니다. 다시 시도해 주세요." }, + "avatar": { + "change": "아바타 변경", + "remove": "아바타 제거", + "uploading": "업로드 중…", + "errors": { + "invalidType": "PNG, JPEG, WEBP 또는 GIF 이미지를 선택해 주세요.", + "tooLarge": "이미지는 5MB 이하여야 합니다.", + "uploadFailed": "아바타 업로드에 실패했습니다. 다시 시도해 주세요.", + "removeFailed": "아바타 제거에 실패했습니다. 다시 시도해 주세요." + } + }, "joinedOn": "{{date}}에 가입", "changePassword": { "title": "비밀번호 변경", diff --git a/apps/web/src/i18n/locales/ko/projects.json b/apps/web/src/i18n/locales/ko/projects.json index 6ca79e2f..4a37e66c 100644 --- a/apps/web/src/i18n/locales/ko/projects.json +++ b/apps/web/src/i18n/locales/ko/projects.json @@ -138,6 +138,17 @@ "envVars": "환경 변수", "activity": "활동" }, + "avatar": { + "change": "아바타 변경", + "remove": "아바타 제거", + "uploading": "업로드 중…", + "errors": { + "invalidType": "PNG, JPEG, WEBP 또는 GIF 이미지를 선택해 주세요.", + "tooLarge": "이미지는 5MB 이하여야 합니다.", + "uploadFailed": "아바타 업로드에 실패했습니다. 다시 시도해 주세요.", + "removeFailed": "아바타 제거에 실패했습니다. 다시 시도해 주세요." + } + }, "overview": { "nameLabel": "이름", "llmConfiguration": "LLM 구성", @@ -457,6 +468,17 @@ "settings": { "general": { "title": "일반", + "avatar": { + "change": "아바타 변경", + "remove": "아바타 제거", + "uploading": "업로드 중…", + "errors": { + "invalidType": "PNG, JPEG, WEBP 또는 GIF 이미지를 선택해 주세요.", + "tooLarge": "이미지는 5MB 이하여야 합니다.", + "uploadFailed": "아바타 업로드에 실패했습니다. 다시 시도해 주세요.", + "removeFailed": "아바타 제거에 실패했습니다. 다시 시도해 주세요." + } + }, "projectNameLabel": "프로젝트 이름", "projectNamePlaceholder": "내 멋진 프로젝트", "taskIdPrefixLabel": "작업 ID 접두사", diff --git a/apps/web/src/i18n/locales/pt-BR/profile.json b/apps/web/src/i18n/locales/pt-BR/profile.json index 2e54ea7c..ae652f1b 100644 --- a/apps/web/src/i18n/locales/pt-BR/profile.json +++ b/apps/web/src/i18n/locales/pt-BR/profile.json @@ -18,6 +18,17 @@ "errors": { "updateFailed": "Falha ao atualizar o perfil. Tente novamente." }, + "avatar": { + "change": "Alterar avatar", + "remove": "Remover avatar", + "uploading": "Enviando…", + "errors": { + "invalidType": "Escolha uma imagem PNG, JPEG, WEBP ou GIF.", + "tooLarge": "A imagem deve ter no máximo 5 MB.", + "uploadFailed": "Falha ao enviar o avatar. Tente novamente.", + "removeFailed": "Falha ao remover o avatar. Tente novamente." + } + }, "joinedOn": "Ingressou em {{date}}", "changePassword": { "title": "Alterar Senha", diff --git a/apps/web/src/i18n/locales/pt-BR/projects.json b/apps/web/src/i18n/locales/pt-BR/projects.json index d2e6a830..f2679bfd 100644 --- a/apps/web/src/i18n/locales/pt-BR/projects.json +++ b/apps/web/src/i18n/locales/pt-BR/projects.json @@ -138,6 +138,17 @@ "envVars": "Ambiente", "activity": "Atividade" }, + "avatar": { + "change": "Alterar avatar", + "remove": "Remover avatar", + "uploading": "Enviando…", + "errors": { + "invalidType": "Escolha uma imagem PNG, JPEG, WEBP ou GIF.", + "tooLarge": "A imagem deve ter no máximo 5 MB.", + "uploadFailed": "Falha ao enviar o avatar. Tente novamente.", + "removeFailed": "Falha ao remover o avatar. Tente novamente." + } + }, "overview": { "nameLabel": "Nome", "llmConfiguration": "Configuração do LLM", @@ -457,6 +468,17 @@ "settings": { "general": { "title": "Geral", + "avatar": { + "change": "Alterar avatar", + "remove": "Remover avatar", + "uploading": "Enviando…", + "errors": { + "invalidType": "Escolha uma imagem PNG, JPEG, WEBP ou GIF.", + "tooLarge": "A imagem deve ter no máximo 5 MB.", + "uploadFailed": "Falha ao enviar o avatar. Tente novamente.", + "removeFailed": "Falha ao remover o avatar. Tente novamente." + } + }, "projectNameLabel": "Nome do projeto", "projectNamePlaceholder": "Meu projeto incrível", "taskIdPrefixLabel": "Prefixo de ID de tarefa", diff --git a/apps/web/src/i18n/locales/ru/profile.json b/apps/web/src/i18n/locales/ru/profile.json index 428b76c0..a885b46e 100644 --- a/apps/web/src/i18n/locales/ru/profile.json +++ b/apps/web/src/i18n/locales/ru/profile.json @@ -18,6 +18,17 @@ "errors": { "updateFailed": "Не удалось обновить профиль. Попробуйте ещё раз." }, + "avatar": { + "change": "Изменить аватар", + "remove": "Удалить аватар", + "uploading": "Загрузка…", + "errors": { + "invalidType": "Выберите изображение в формате PNG, JPEG, WEBP или GIF.", + "tooLarge": "Размер изображения не должен превышать 5 МБ.", + "uploadFailed": "Не удалось загрузить аватар. Попробуйте снова.", + "removeFailed": "Не удалось удалить аватар. Попробуйте снова." + } + }, "joinedOn": "Присоединился {{date}}", "changePassword": { "title": "Сменить пароль", diff --git a/apps/web/src/i18n/locales/ru/projects.json b/apps/web/src/i18n/locales/ru/projects.json index b3d38acc..bbf20c49 100644 --- a/apps/web/src/i18n/locales/ru/projects.json +++ b/apps/web/src/i18n/locales/ru/projects.json @@ -138,6 +138,17 @@ "envVars": "Окружение", "activity": "Активность" }, + "avatar": { + "change": "Изменить аватар", + "remove": "Удалить аватар", + "uploading": "Загрузка…", + "errors": { + "invalidType": "Выберите изображение в формате PNG, JPEG, WEBP или GIF.", + "tooLarge": "Размер изображения не должен превышать 5 МБ.", + "uploadFailed": "Не удалось загрузить аватар. Попробуйте снова.", + "removeFailed": "Не удалось удалить аватар. Попробуйте снова." + } + }, "overview": { "nameLabel": "Имя", "llmConfiguration": "Конфигурация LLM", @@ -469,6 +480,17 @@ "settings": { "general": { "title": "Общие", + "avatar": { + "change": "Изменить аватар", + "remove": "Удалить аватар", + "uploading": "Загрузка…", + "errors": { + "invalidType": "Выберите изображение в формате PNG, JPEG, WEBP или GIF.", + "tooLarge": "Размер изображения не должен превышать 5 МБ.", + "uploadFailed": "Не удалось загрузить аватар. Попробуйте снова.", + "removeFailed": "Не удалось удалить аватар. Попробуйте снова." + } + }, "projectNameLabel": "Название проекта", "projectNamePlaceholder": "Мой отличный проект", "taskIdPrefixLabel": "Префикс ID задач", diff --git a/apps/web/src/i18n/locales/vi/profile.json b/apps/web/src/i18n/locales/vi/profile.json index b1cc5c3c..59eeebff 100644 --- a/apps/web/src/i18n/locales/vi/profile.json +++ b/apps/web/src/i18n/locales/vi/profile.json @@ -18,6 +18,17 @@ "errors": { "updateFailed": "Cập nhật hồ sơ thất bại. Vui lòng thử lại." }, + "avatar": { + "change": "Đổi ảnh đại diện", + "remove": "Xóa ảnh đại diện", + "uploading": "Đang tải lên…", + "errors": { + "invalidType": "Vui lòng chọn ảnh định dạng PNG, JPEG, WEBP hoặc GIF.", + "tooLarge": "Ảnh phải nhỏ hơn hoặc bằng 5 MB.", + "uploadFailed": "Tải ảnh đại diện lên thất bại. Vui lòng thử lại.", + "removeFailed": "Xóa ảnh đại diện thất bại. Vui lòng thử lại." + } + }, "joinedOn": "Tham gia {{date}}", "changePassword": { "title": "Đổi mật khẩu", diff --git a/apps/web/src/i18n/locales/vi/projects.json b/apps/web/src/i18n/locales/vi/projects.json index 52688bf1..a99d1e92 100644 --- a/apps/web/src/i18n/locales/vi/projects.json +++ b/apps/web/src/i18n/locales/vi/projects.json @@ -138,6 +138,17 @@ "envVars": "Môi trường", "activity": "Hoạt động" }, + "avatar": { + "change": "Đổi ảnh đại diện", + "remove": "Xóa ảnh đại diện", + "uploading": "Đang tải lên…", + "errors": { + "invalidType": "Vui lòng chọn ảnh định dạng PNG, JPEG, WEBP hoặc GIF.", + "tooLarge": "Ảnh phải nhỏ hơn hoặc bằng 5 MB.", + "uploadFailed": "Tải ảnh đại diện lên thất bại. Vui lòng thử lại.", + "removeFailed": "Xóa ảnh đại diện thất bại. Vui lòng thử lại." + } + }, "overview": { "nameLabel": "Tên", "llmConfiguration": "Cấu hình LLM", @@ -457,6 +468,17 @@ "settings": { "general": { "title": "Chung", + "avatar": { + "change": "Đổi ảnh đại diện", + "remove": "Xóa ảnh đại diện", + "uploading": "Đang tải lên…", + "errors": { + "invalidType": "Vui lòng chọn ảnh định dạng PNG, JPEG, WEBP hoặc GIF.", + "tooLarge": "Ảnh phải nhỏ hơn hoặc bằng 5 MB.", + "uploadFailed": "Tải ảnh đại diện lên thất bại. Vui lòng thử lại.", + "removeFailed": "Xóa ảnh đại diện thất bại. Vui lòng thử lại." + } + }, "projectNameLabel": "Tên dự án", "projectNamePlaceholder": "Dự án tuyệt vời của tôi", "taskIdPrefixLabel": "Tiền tố mã nhiệm vụ", diff --git a/apps/web/src/i18n/locales/zh-CN/profile.json b/apps/web/src/i18n/locales/zh-CN/profile.json index bed1d7d2..0590c7f5 100644 --- a/apps/web/src/i18n/locales/zh-CN/profile.json +++ b/apps/web/src/i18n/locales/zh-CN/profile.json @@ -18,6 +18,17 @@ "errors": { "updateFailed": "更新资料失败,请重试。" }, + "avatar": { + "change": "更换头像", + "remove": "移除头像", + "uploading": "上传中…", + "errors": { + "invalidType": "请选择 PNG、JPEG、WEBP 或 GIF 格式的图片。", + "tooLarge": "图片大小不能超过 5 MB。", + "uploadFailed": "头像上传失败,请重试。", + "removeFailed": "头像移除失败,请重试。" + } + }, "joinedOn": "加入于 {{date}}", "changePassword": { "title": "修改密码", diff --git a/apps/web/src/i18n/locales/zh-CN/projects.json b/apps/web/src/i18n/locales/zh-CN/projects.json index 22d9ffc9..8ab100b2 100644 --- a/apps/web/src/i18n/locales/zh-CN/projects.json +++ b/apps/web/src/i18n/locales/zh-CN/projects.json @@ -138,6 +138,17 @@ "envVars": "环境变量", "activity": "动态" }, + "avatar": { + "change": "更换头像", + "remove": "移除头像", + "uploading": "上传中…", + "errors": { + "invalidType": "请选择 PNG、JPEG、WEBP 或 GIF 格式的图片。", + "tooLarge": "图片大小不能超过 5 MB。", + "uploadFailed": "头像上传失败,请重试。", + "removeFailed": "头像移除失败,请重试。" + } + }, "overview": { "nameLabel": "名称", "llmConfiguration": "LLM 配置", @@ -457,6 +468,17 @@ "settings": { "general": { "title": "通用", + "avatar": { + "change": "更换头像", + "remove": "移除头像", + "uploading": "上传中…", + "errors": { + "invalidType": "请选择 PNG、JPEG、WEBP 或 GIF 格式的图片。", + "tooLarge": "图片大小不能超过 5 MB。", + "uploadFailed": "头像上传失败,请重试。", + "removeFailed": "头像移除失败,请重试。" + } + }, "projectNameLabel": "项目名称", "projectNamePlaceholder": "我的精彩项目", "taskIdPrefixLabel": "任务编号前缀", diff --git a/apps/web/src/lib/admin-api.ts b/apps/web/src/lib/admin-api.ts index a05848a1..8077dbd4 100644 --- a/apps/web/src/lib/admin-api.ts +++ b/apps/web/src/lib/admin-api.ts @@ -73,6 +73,8 @@ export interface User { full_name: string; role: string; must_change_password: boolean; + avatar_url?: string | null; + avatar_thumb_url?: string | null; created_at: string; } diff --git a/apps/web/src/lib/agent-api.ts b/apps/web/src/lib/agent-api.ts index 0ab63b33..d1601afd 100644 --- a/apps/web/src/lib/agent-api.ts +++ b/apps/web/src/lib/agent-api.ts @@ -127,6 +127,7 @@ export interface Agent { name: string; handle: string; avatar_url?: string | null; + avatar_thumb_url?: string | null; agent_type: AgentType; llm_provider: string; llm_model: string; diff --git a/apps/web/src/lib/auth-api.ts b/apps/web/src/lib/auth-api.ts index 9f2890e0..d044c4b5 100644 --- a/apps/web/src/lib/auth-api.ts +++ b/apps/web/src/lib/auth-api.ts @@ -11,6 +11,8 @@ export interface User { full_name: string; role: string; must_change_password: boolean; + avatar_url?: string | null; + avatar_thumb_url?: string | null; created_at: string; } diff --git a/apps/web/src/lib/avatar-api.ts b/apps/web/src/lib/avatar-api.ts new file mode 100644 index 00000000..8fadb594 --- /dev/null +++ b/apps/web/src/lib/avatar-api.ts @@ -0,0 +1,127 @@ +import { apiClient } from "./api-client"; +import type { SuccessEnvelope } from "./api-error"; + +// ── Shared constants ───────────────────────────────────────────────────────── +// Mirrors attachmentdom.MaxAvatarUploadSize / AvatarContentTypes on the server +// — checking client-side first gives instant feedback, but the server always +// re-validates. + +export const MAX_AVATAR_UPLOAD_SIZE = 5 * 1024 * 1024; // 5 MiB +export const ACCEPTED_AVATAR_CONTENT_TYPES = [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", +]; + +/** Returns an error message if file isn't an acceptable avatar upload, else null. */ +export function validateAvatarFile(file: File): string | null { + if (!ACCEPTED_AVATAR_CONTENT_TYPES.includes(file.type)) { + return "invalidType"; + } + if (file.size > MAX_AVATAR_UPLOAD_SIZE) { + return "tooLarge"; + } + return null; +} + +// ── API shapes ──────────────────────────────────────────────────────────────── + +interface AvatarUploadSession { + file_id: string; + upload_url?: string; +} + +export interface AvatarResult { + avatar_url?: string | null; + avatar_thumb_url?: string | null; +} + +// ── API calls ───────────────────────────────────────────────────────────────── + +async function initiateAvatarUpload( + basePath: string, + payload: { file_name: string; content_type: string; file_size: number }, +): Promise { + const { data } = await apiClient.instance.post< + SuccessEnvelope + >(`${basePath}/avatar/initiate-upload`, payload); + return data.data; +} + +// The server omits avatar_url/avatar_thumb_url entirely (rather than sending +// them as null) when an owner has no avatar, since the DTO fields are +// `*string` with `json:",omitempty"`. Callers merge this result onto cached +// query data with `{ ...old, ...result }` — spreading an object that's +// missing a key leaves the stale value from `old` in place, so a removal +// would silently fail to clear the avatar until the next full refetch. +// Normalizing both keys to be always-present (real URL or explicit null) +// here means every caller's spread-merge just works. +function normalizeAvatarResult(result: AvatarResult): Required { + return { + avatar_url: result.avatar_url ?? null, + avatar_thumb_url: result.avatar_thumb_url ?? null, + }; +} + +async function completeAvatarUpload( + basePath: string, + fileId: string, +): Promise { + const { data } = await apiClient.instance.post>( + `${basePath}/avatar/complete-upload`, + { file_id: fileId }, + ); + return normalizeAvatarResult(data.data); +} + +/** + * Uploads a single image file directly to the object store via a presigned + * URL, then confirms with the API so the server can derive the "full" and + * "thumb" variants. `basePath` selects the owner — `/users/me`, + * `/projects/{projectId}/agents/{agentId}`, or `/admin/agents/{agentId}`. + */ +export async function uploadAvatar( + basePath: string, + file: File, + onProgress?: (loaded: number, total: number) => void, +): Promise { + const session = await initiateAvatarUpload(basePath, { + file_name: file.name, + content_type: file.type || "application/octet-stream", + file_size: file.size, + }); + if (!session.upload_url) { + throw new Error("Server returned no upload URL"); + } + const uploadUrl = session.upload_url; + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", uploadUrl); + xhr.setRequestHeader( + "Content-Type", + file.type || "application/octet-stream", + ); + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) onProgress?.(e.loaded, e.total); + }); + xhr.addEventListener("load", () => + xhr.status >= 200 && xhr.status < 300 + ? resolve() + : reject(new Error(`Upload failed: ${xhr.status}`)), + ); + xhr.addEventListener("error", () => reject(new Error("Upload error"))); + xhr.send(file); + }); + + return completeAvatarUpload(basePath, session.file_id); +} + +/** Removes the avatar at basePath (see uploadAvatar for basePath shapes). */ +export async function removeAvatar(basePath: string): Promise { + const { data } = await apiClient.instance.delete< + SuccessEnvelope + >(`${basePath}/avatar`); + return normalizeAvatarResult(data.data); +} diff --git a/apps/web/src/lib/doc-api.ts b/apps/web/src/lib/doc-api.ts index e256daca..4b474dfc 100644 --- a/apps/web/src/lib/doc-api.ts +++ b/apps/web/src/lib/doc-api.ts @@ -79,6 +79,8 @@ export interface DocActivity { actor_id: string | null; actor_name: string; actor_username: string; + actor_avatar_url?: string | null; + actor_avatar_thumb_url?: string | null; activity_type: DocActivityType; content: string | DocActivityContent | null; created_at: string; diff --git a/apps/web/src/lib/interaction-api.ts b/apps/web/src/lib/interaction-api.ts index 3ee97832..5a712e62 100644 --- a/apps/web/src/lib/interaction-api.ts +++ b/apps/web/src/lib/interaction-api.ts @@ -872,6 +872,8 @@ export interface Activity { actor_id?: string | null; actor_name: string; actor_username: string; + actor_avatar_url?: string | null; + actor_avatar_thumb_url?: string | null; activity_type: string; content: Record | unknown[]; created_at: string; diff --git a/apps/web/src/lib/mention-api.ts b/apps/web/src/lib/mention-api.ts index 875c0996..ea1d02c3 100644 --- a/apps/web/src/lib/mention-api.ts +++ b/apps/web/src/lib/mention-api.ts @@ -7,7 +7,8 @@ export interface TeamMember { id: string; name: string; username: string; - avatar?: string | null | undefined; + avatarUrl?: string | null | undefined; + avatarThumbUrl?: string | null | undefined; } export interface MentionableTask { @@ -80,7 +81,8 @@ export function useMentionData(projectId?: string | null) { : member.user_id, name: member.full_name, username: member.username, - avatar: member.full_name.slice(0, 2).toUpperCase() || undefined, + avatarUrl: member.avatar_url, + avatarThumbUrl: member.avatar_thumb_url, })); const mentionDocs: MentionableDocument[] = documents.map((doc) => ({ diff --git a/apps/web/src/lib/project-api.ts b/apps/web/src/lib/project-api.ts index 4d43eb06..04c610df 100644 --- a/apps/web/src/lib/project-api.ts +++ b/apps/web/src/lib/project-api.ts @@ -12,10 +12,24 @@ export interface Project { is_public: boolean; task_id_prefix: string; settings: Record; + avatar_url?: string | null; + avatar_thumb_url?: string | null; created_by?: string; created_at: string; } +/** Text-avatar fallback for a project — one letter per word, up to two + * words (e.g. "Test Project" -> "TP"). Shared so every surface (sidebar, + * project cards, settings) renders the same initials for the same name. */ +export function getProjectInitials(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((w) => w[0].toUpperCase()) + .join(""); +} + export interface ProjectListResult { items: Project[]; total: number; @@ -41,6 +55,13 @@ export interface ProjectMember { agent_id?: string; agent_name?: string; agent_handle?: string; + avatar_url?: string | null; + avatar_thumb_url?: string | null; + // Only meaningful when member_type is "agent" — used to pick a default + // provider-logo avatar when this member has no avatar_url of its own. + agent_type?: string; // "llm" | "acp" + agent_llm_provider?: string; + agent_acp_provider?: string | null; } export interface ProjectRole { diff --git a/apps/web/src/lib/provider-logos.ts b/apps/web/src/lib/provider-logos.ts new file mode 100644 index 00000000..4a733643 --- /dev/null +++ b/apps/web/src/lib/provider-logos.ts @@ -0,0 +1,139 @@ +import type { Agent } from "./agent-api"; +import type { ProjectMember } from "./project-api"; + +// Default agent avatar placeholders, shown when an agent has no custom +// uploaded avatar — one recognizable brand mark per LLM/ACP provider instead +// of bare initials. Assets are static SVGs served from public/provider-logos +// (sourced from @lobehub/icons-static-svg, MIT licensed), not part of the +// upload/S3 pipeline: they're a pure display fallback, same tier as the +// initials fallback they replace. +// +// Deliberately covers only the mainstream providers users are realistically +// likely to pick — not the full ~115-entry raw LLM catalog (most of which +// are non-chat or extremely niche). Anything unmapped here falls back to +// initials exactly as before. +const LLM_PROVIDER_LOGOS: Record = { + anthropic: "/provider-logos/anthropic.svg", + openai: "/provider-logos/openai.svg", + gemini: "/provider-logos/gemini.svg", + mistral: "/provider-logos/mistral.svg", + cohere: "/provider-logos/cohere.svg", + xai: "/provider-logos/xai.svg", + deepseek: "/provider-logos/deepseek.svg", + groq: "/provider-logos/groq.svg", + perplexity: "/provider-logos/perplexity.svg", + openrouter: "/provider-logos/openrouter.svg", + ollama: "/provider-logos/ollama.svg", + azure: "/provider-logos/azure.svg", + bedrock: "/provider-logos/bedrock.svg", + meta_llama: "/provider-logos/meta_llama.svg", + vertex_ai: "/provider-logos/vertex_ai.svg", +}; + +// "custom" has no fixed provider identity, so it's intentionally unmapped — +// falls back to initials like any other unmapped provider. +const ACP_PROVIDER_LOGOS: Record = { + "claude-code": "/provider-logos/claude-code.svg", + codex: "/provider-logos/codex.svg", + "gemini-cli": "/provider-logos/gemini-cli.svg", +}; + +function lookupProviderLogo( + agentType: string | undefined, + llmProvider: string | undefined, + acpProvider: string | null | undefined, +): string | undefined { + if (agentType === "acp") { + return acpProvider ? ACP_PROVIDER_LOGOS[acpProvider] : undefined; + } + return llmProvider + ? LLM_PROVIDER_LOGOS[llmProvider.toLowerCase()] + : undefined; +} + +/** + * Returns the default avatar for an agent's configured provider, or + * undefined if the provider isn't mapped (caller should fall back to + * initials in that case, same as when the agent has no avatar at all). + */ +function getDefaultAgentAvatar( + agent: Pick, +): string | undefined { + return lookupProviderLogo( + agent.agent_type, + agent.llm_provider, + agent.acp_provider, + ); +} + +/** + * getDefaultAgentAvatar's sibling for the lighter-weight ProjectMember + * projection (team page, assignee/reporter pickers, task chips) — same + * lookup, different field names since those come from a JOIN rather than + * the full Agent record. Returns undefined for human members. + */ +function getDefaultMemberAvatar( + member: Pick< + ProjectMember, + "member_type" | "agent_type" | "agent_llm_provider" | "agent_acp_provider" + >, +): string | undefined { + if (member.member_type !== "agent") return undefined; + return lookupProviderLogo( + member.agent_type, + member.agent_llm_provider, + member.agent_acp_provider, + ); +} + +// ── Resolved avatar URL — the single "avatar -> default avatar" step ─────────── +// +// Every display site ultimately wants one thing: the URL to render, with a +// real upload always winning over the provider-logo default. Previously each +// call site re-derived that itself via `x.avatar_thumb_url ?? getDefaultXAvatar(x)` +// — easy to typo the field, forget the `??`, or omit the default entirely (all +// three happened across different components over time). These two functions +// are the only place that priority is expressed; every caller goes through +// one of them instead of inlining the merge. The remaining, final tier — +// falling back to initials/an icon when this returns undefined — stays with +// the rendering components (EntityAvatarContent / AvatarUpload), since that +// part is display markup, not a resolution rule. + +/** + * Resolves an agent's own record to the avatar URL to render: its real + * upload if present, else its provider-logo default, else undefined. `size` + * picks which uploaded variant to prefer — "full" for large headers, "thumb" + * (default) for every small chip/list use. + */ +export function resolveAgentAvatarUrl( + agent: Pick< + Agent, + | "avatar_url" + | "avatar_thumb_url" + | "agent_type" + | "llm_provider" + | "acp_provider" + >, + size: "full" | "thumb" = "thumb", +): string | undefined { + const uploaded = size === "full" ? agent.avatar_url : agent.avatar_thumb_url; + return uploaded ?? getDefaultAgentAvatar(agent); +} + +/** + * resolveAgentAvatarUrl's sibling for the lighter-weight ProjectMember + * projection (team page, assignee/reporter pickers, task chips). Always + * thumb-sized — ProjectMember-based surfaces never need the full variant. + */ +export function resolveMemberAvatarUrl( + member: Pick< + ProjectMember, + | "avatar_thumb_url" + | "member_type" + | "agent_type" + | "agent_llm_provider" + | "agent_acp_provider" + >, +): string | undefined { + return member.avatar_thumb_url ?? getDefaultMemberAvatar(member); +} diff --git a/apps/web/src/routes/_authenticated/home/index.tsx b/apps/web/src/routes/_authenticated/home/index.tsx index 1280659a..55ba3b65 100644 --- a/apps/web/src/routes/_authenticated/home/index.tsx +++ b/apps/web/src/routes/_authenticated/home/index.tsx @@ -18,6 +18,7 @@ import { type ComponentType, useState } from "react"; import { useTranslation } from "react-i18next"; import { AssignedTasksList } from "@/components/home/assigned-tasks-list"; import { UpdateBanner } from "@/components/home/UpdateBanner"; +import { EntityAvatarContent } from "@/components/shared/entity-avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -41,6 +42,7 @@ import { currentUserQueryOptions } from "@/lib/auth-api"; import { assignedTasksQueryOptions } from "@/lib/interaction-api"; import { createProject, + getProjectInitials, type Project, projectsQueryOptions, workspaceStatsQueryOptions, @@ -328,12 +330,7 @@ function CreateProjectDialog({ function ProjectCard({ project }: { project: Project }) { const { t } = useTranslation("shared"); - const initials = project.name - .split(/\s+/) - .filter(Boolean) - .slice(0, 2) - .map((w) => w[0].toUpperCase()) - .join(""); + const initials = getProjectInitials(project.name); const formattedDate = new Date(project.created_at).toLocaleDateString( "en-US", @@ -349,7 +346,9 @@ function ProjectCard({ project }: { project: Project }) {
- {initials || } + + {initials || } +
diff --git a/apps/web/src/routes/_authenticated/profile/index.tsx b/apps/web/src/routes/_authenticated/profile/index.tsx index 3d88fa81..049227ae 100644 --- a/apps/web/src/routes/_authenticated/profile/index.tsx +++ b/apps/web/src/routes/_authenticated/profile/index.tsx @@ -4,7 +4,7 @@ import { CalendarDays, User } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { AvatarUpload } from "@/components/shared/avatar-upload"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -22,19 +22,21 @@ import { Skeleton } from "@/components/ui/skeleton"; import { apiClient } from "@/lib/api-client"; import type { SuccessEnvelope } from "@/lib/api-error"; import type { User as UserType } from "@/lib/auth-api"; -import { currentUserQueryOptions } from "@/lib/auth-api"; +import { + currentUserOptionalQueryOptions, + currentUserQueryOptions, +} from "@/lib/auth-api"; import { formatDate } from "@/lib/format-date"; export const Route = createFileRoute("/_authenticated/profile/")({ component: ProfilePage, }); -async function updateProfile( - userId: string, - payload: { full_name: string }, -): Promise { +async function updateProfile(payload: { + full_name: string; +}): Promise { const { data } = await apiClient.instance.patch>( - `/users/${userId}`, + "/users/me", payload, ); return data.data; @@ -60,12 +62,7 @@ function ProfilePage() { const [serverError, setServerError] = useState(null); const mutation = useMutation({ - mutationFn: () => { - if (!user) { - throw new Error("User is not loaded"); - } - return updateProfile(user.id, { full_name: fullName.trim() }); - }, + mutationFn: () => updateProfile({ full_name: fullName.trim() }), onSuccess: (updated) => { queryClient.setQueryData(currentUserQueryOptions.queryKey, updated); setEditing(false); @@ -181,11 +178,36 @@ function ProfilePage() {
- - - {initials} - - + { + // Two separate caches back "who am I" (this page's own query, + // and user-menu.tsx's optional variant used in the top-nav) — + // both need the update or one keeps showing the pre-upload + // avatar until its staleTime lapses. + queryClient.setQueryData( + currentUserQueryOptions.queryKey, + (old) => (old ? { ...old, ...result } : old), + ); + queryClient.setQueryData( + currentUserOptionalQueryOptions.queryKey, + (old) => (old ? { ...old, ...result } : old), + ); + }} + />
{displayName} diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx index 856397da..0297d717 100644 --- a/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx +++ b/apps/web/src/routes/_authenticated/projects/$projectId/team/index.tsx @@ -20,7 +20,7 @@ import { } from "lucide-react"; import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -64,6 +64,10 @@ import { removeProjectMember, updateProjectMemberRole, } from "@/lib/project-api"; +import { + resolveAgentAvatarUrl, + resolveMemberAvatarUrl, +} from "@/lib/provider-logos"; import { createLoadMoreScrollHandler } from "@/lib/scroll-pagination"; export const Route = createFileRoute( @@ -107,6 +111,9 @@ function UserPickerItem({ onClick={() => onSelect(user)} > + {user.avatar_thumb_url ? ( + + ) : null} {getInitials(display)} @@ -150,6 +157,9 @@ function AddMemberDialog({ const [error, setError] = useState(null); const searchRef = useRef(null); const canReadUsers = can("users.read"); + const selectedAgentAvatarUrl = selectedAgent + ? resolveAgentAvatarUrl(selectedAgent) + : undefined; const { data: allAgents = [], isLoading: isLoadingAgents } = useQuery({ ...chattableAgentsQueryOptions, @@ -304,6 +314,9 @@ function AddMemberDialog({ {selectedAgent ? (
+ {selectedAgentAvatarUrl ? ( + + ) : null} @@ -335,28 +348,32 @@ function AddMemberDialog({ {t("team.addMemberDialog.noAgentsAvailable")}

) : ( - availableAgents.map((agent) => ( - - )) + availableAgents.map((agent) => { + const avatarUrl = resolveAgentAvatarUrl(agent); + return ( + + ); + }) )}
)} @@ -369,6 +386,9 @@ function AddMemberDialog({ {selectedUser ? (
+ {selectedUser.avatar_thumb_url ? ( + + ) : null} {getInitials( selectedUser.full_name || selectedUser.username, @@ -665,10 +685,12 @@ function MemberRow({ member.member_type === "agent" || member.username.startsWith("bot-") || member.role_name.toLowerCase().includes("agent"); + const memberAvatarUrl = resolveMemberAvatarUrl(member); return (
+ {memberAvatarUrl ? : null} {isBot ? : getInitials(display)} diff --git a/services/api/go.mod b/services/api/go.mod index 24af81b1..6a77881c 100644 --- a/services/api/go.mod +++ b/services/api/go.mod @@ -20,6 +20,8 @@ require ( golang.org/x/crypto v0.52.0 ) +require golang.org/x/image v0.44.0 // indirect + require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -91,8 +93,8 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.22.0 golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.40.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/services/api/go.sum b/services/api/go.sum index 4f296fe3..894202f4 100644 --- a/services/api/go.sum +++ b/services/api/go.sum @@ -205,8 +205,12 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -217,6 +221,8 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/services/api/internal/bootstrap/app.go b/services/api/internal/bootstrap/app.go index 3ad58c7a..d4f01ff5 100644 --- a/services/api/internal/bootstrap/app.go +++ b/services/api/internal/bootstrap/app.go @@ -144,7 +144,8 @@ func New(cfg *config.Config) (*App, error) { userService := usersvc.New(userRepo, permissionStore, globalRoleRepo) agentRepo := pgRepo.NewAgentRepository(db) globalRoleService := globalrolesvc.NewCachedService(globalrolesvc.New(globalRoleRepo, agentRepo), cacheStore, cfg.Cache.ConfigTTL, log) - projectService := projectsvc.NewCachedService(projectsvc.New(projectRepo, taskRepo, agentRepo), cacheStore, cfg.Cache.ProjectTTL, cfg.Cache.ConfigTTL, log) + projectServiceBase := projectsvc.New(projectRepo, taskRepo, agentRepo) + projectService := projectsvc.NewCachedService(projectServiceBase, cacheStore, cfg.Cache.ProjectTTL, cfg.Cache.ConfigTTL, log) taskService := tasksvc.NewCachedService(tasksvc.New(taskRepo).WithAutomationStatusChecker(rawAutomationRepo), cacheStore, cfg.Cache.ConfigTTL, log) sprintService := sprintsvc.NewCachedSprintService(sprintsvc.New(sprintRepo, taskRepo, publisher), cacheStore, cfg.Cache.SprintTTL, log) viewService := sprintsvc.NewCachedViewService(sprintsvc.NewViewService(viewRepo, publisher), cacheStore, cfg.Cache.SprintTTL, log) @@ -205,6 +206,9 @@ func New(cfg *config.Config) (*App, error) { } attachmentService := attachmentsvc.New(attachmentRepo, attachmentsvc.NewTaskOwnerChecker(taskRepo), storageClient, cfg.Storage.Bucket) + userService = userService.WithAvatarService(attachmentService) + agentService = agentService.WithAvatarService(attachmentService) + projectServiceBase.WithAvatarService(attachmentService) // --- API Key management ------------------------------------------------- apiKeyRepo := pgRepo.NewAPIKeyRepository(db) @@ -307,7 +311,8 @@ func New(cfg *config.Config) (*App, error) { agentHandler := handler.NewAgentHandler(agentService, cfg.AIAgentURL, cfg.AIAgentInternalKey, cfg.Server.PublicURL). WithActivityRecorder(activityService). WithMemberRepo(projectRepo). - WithGlobalPermissionReader(permissionStore) + WithGlobalPermissionReader(permissionStore). + WithAvatarService(attachmentService) convHandler := handler.NewConversationHandler(agentService) automationHandler := handler.NewAutomationHandler(automationService).WithPluginRuntime(pluginRuntime) @@ -326,7 +331,7 @@ func New(cfg *config.Config) (*App, error) { Health: handler.NewHealthHandler(), Version: handler.NewVersionHandler(cfg.Release, cacheStore, log), Auth: handler.NewAuthHandler(authService, cookieCfg), - User: handler.NewUserHandler(userService, authService), + User: handler.NewUserHandler(userService, authService).WithAvatarService(attachmentService), GlobalRole: handler.NewGlobalRoleHandler(globalRoleService), ProjectVisibilitySvc: projectService, Project: handler.NewProjectHandler( @@ -334,17 +339,19 @@ func New(cfg *config.Config) (*App, error) { authorizer, handler.WithProjectDefaultViews(viewService, taskService), handler.WithProjectStatsServices(taskService, userService), + handler.WithProjectAvatarService(attachmentService), ), Task: handler.NewTaskHandler(taskService, viewService, activityService, handler.WithTaskPublisher(publisher), - handler.WithTaskAssignedProjectService(projectService)), + handler.WithTaskAssignedProjectService(projectService), + handler.WithTaskAvatarService(attachmentService)), Sprint: handler.NewSprintHandler(sprintService, viewService, handler.WithSprintDefaultTaskTypes(taskService), handler.WithSprintDefaultTaskStatuses(taskService), ), View: handler.NewViewHandler(viewService), Attachment: handler.NewAttachmentHandler(attachmentService), - Document: handler.NewDocumentHandler(docService, docActivityService), + Document: handler.NewDocumentHandler(docService, docActivityService).WithDocAvatarService(attachmentService), DocFile: handler.NewDocFileHandler(attachmentService), Notification: handler.NewNotificationHandler(notificationService), APIKey: handler.NewAPIKeyHandler(apiKeyService), diff --git a/services/api/internal/domain/agent/entity.go b/services/api/internal/domain/agent/entity.go index 8d67863e..00cfcfc3 100644 --- a/services/api/internal/domain/agent/entity.go +++ b/services/api/internal/domain/agent/entity.go @@ -26,10 +26,14 @@ type Agent struct { // via admin-shaped tools (create users, manage global roles, manage // projects) when acting with no project context. Only ever set for // AgentScopeGlobal agents; nil means no global-scope permissions. - GlobalRoleID *uuid.UUID - Name string - Handle string - AvatarURL *string + GlobalRoleID *uuid.UUID + Name string + Handle string + // AvatarKey and AvatarThumbKey are object-storage keys for the two + // server-generated avatar variants (256x256 full, 64x64 thumb). Both nil + // when no avatar has been uploaded. See attachmentdom.AvatarService. + AvatarKey *string + AvatarThumbKey *string AgentType string // llm | acp LLMProvider string LLMModel string diff --git a/services/api/internal/domain/agent/service.go b/services/api/internal/domain/agent/service.go index 47c6de71..20d68af2 100644 --- a/services/api/internal/domain/agent/service.go +++ b/services/api/internal/domain/agent/service.go @@ -4,6 +4,8 @@ import ( "context" "github.com/google/uuid" + + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" ) // Service is the combined AI Agent service contract. @@ -40,6 +42,13 @@ type AgentService interface { // instead of to whichever human generated the command. GenerateAgentMCPKey(ctx context.Context, projectID, agentID uuid.UUID) (plaintext string, err error) + // InitiateAvatarUpload starts an avatar upload for a project-scoped agent. + InitiateAvatarUpload(ctx context.Context, projectID, agentID uuid.UUID, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) + // CompleteAvatarUpload finishes an avatar upload for a project-scoped agent. + CompleteAvatarUpload(ctx context.Context, projectID, agentID, fileID uuid.UUID) (*Agent, error) + // RemoveAvatar clears a project-scoped agent's avatar. + RemoveAvatar(ctx context.Context, projectID, agentID uuid.UUID) (*Agent, error) + // -- Global agents (AgentScope == AgentScopeGlobal). See the Agent doc // comment. These never take a projectID: a global agent has none of its // own, and is attached to projects only indirectly via project_members @@ -64,6 +73,13 @@ type AgentService interface { // sibling — ownership verified via GetGlobalAgent instead of a // projectID match. GenerateGlobalAgentMCPKey(ctx context.Context, agentID uuid.UUID) (plaintext string, err error) + + // InitiateGlobalAvatarUpload is InitiateAvatarUpload's global-agent sibling. + InitiateGlobalAvatarUpload(ctx context.Context, agentID uuid.UUID, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) + // CompleteGlobalAvatarUpload is CompleteAvatarUpload's global-agent sibling. + CompleteGlobalAvatarUpload(ctx context.Context, agentID, fileID uuid.UUID) (*Agent, error) + // RemoveGlobalAvatar is RemoveAvatar's global-agent sibling. + RemoveGlobalAvatar(ctx context.Context, agentID uuid.UUID) (*Agent, error) } // MCPServerService defines MCP server CRUD use cases. diff --git a/services/api/internal/domain/attachment/avatar_service.go b/services/api/internal/domain/attachment/avatar_service.go new file mode 100644 index 00000000..f2665417 --- /dev/null +++ b/services/api/internal/domain/attachment/avatar_service.go @@ -0,0 +1,87 @@ +package attachmentdom + +import ( + "context" + + "github.com/google/uuid" +) + +// AvatarOwnerKind discriminates which table an avatar belongs to. It is used +// only to namespace the object-storage key (avatars/{kind}/{ownerID}/...) +// and to verify a completed upload belongs to the claimed owner. +type AvatarOwnerKind string + +// AvatarOwnerKind values. +const ( + AvatarOwnerUser AvatarOwnerKind = "users" + AvatarOwnerAgent AvatarOwnerKind = "agents" + AvatarOwnerProject AvatarOwnerKind = "projects" +) + +// AvatarService manages avatar uploads for users and agents. Unlike the task +// attachment flow there is no join table and no client-visible "original" — +// on completion the server re-encodes the upload into two fixed-size PNG +// variants (full + thumb) and the owner (user/agent service) persists their +// storage keys directly on its own row. See service/attachment/avatar_service.go. +type AvatarService interface { + // InitiateAvatarUpload creates a pending File record and returns a + // presigned single-part PUT URL. Rejects non-image content types and + // files over MaxAvatarUploadSize. + InitiateAvatarUpload(ctx context.Context, in AvatarUploadInput) (*UploadSession, error) + + // CompleteAvatarUpload downloads the uploaded bytes, decodes them, + // center-crops and resizes into "full" (256x256) and "thumb" (64x64) + // PNG variants, uploads both, deletes the raw upload object, and + // returns the two derived storage keys for the caller to persist. + CompleteAvatarUpload(ctx context.Context, in AvatarCompleteInput) (*AvatarKeys, error) + + // ResolveAvatarURL returns a short-lived presigned GET URL for the given + // storage key, or nil if key is nil (no avatar set). Presigning is a + // local computation (no network round-trip), so this is cheap to call + // per-item in list responses. + ResolveAvatarURL(ctx context.Context, key *string) (*string, error) + + // DeleteAvatarObjects best-effort deletes the given storage keys (nil + // and empty keys are skipped). Used to clean up the previous avatar + // after a replace, or both keys on removal. Errors are logged, not + // returned — a stray orphaned object must never block the request that + // triggered the replace/removal. + DeleteAvatarObjects(ctx context.Context, keys ...*string) +} + +// MaxAvatarUploadSize caps the raw (pre-resize) avatar upload. Comfortably +// under storage.MultipartThreshold so avatars never need the multipart path. +const MaxAvatarUploadSize = 5 * 1024 * 1024 // 5 MiB + +// AvatarContentTypes is the whitelist of accepted raw upload content types. +var AvatarContentTypes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, + "image/gif": true, +} + +// AvatarUploadInput carries the client-supplied metadata for initiating an +// avatar upload. +type AvatarUploadInput struct { + OwnerKind AvatarOwnerKind + OwnerID uuid.UUID + FileName string + ContentType string + FileSize int64 + UploadedBy uuid.UUID +} + +// AvatarCompleteInput carries parameters for finishing an avatar upload. +type AvatarCompleteInput struct { + OwnerKind AvatarOwnerKind + OwnerID uuid.UUID + FileID uuid.UUID +} + +// AvatarKeys holds the storage keys of the two derived avatar variants, +// meant to be persisted directly on the owning user/agent row. +type AvatarKeys struct { + Key string // 256x256 "full" variant + ThumbKey string // 64x64 "thumb" variant +} diff --git a/services/api/internal/domain/attachment/errors.go b/services/api/internal/domain/attachment/errors.go index 5077488c..6833d68a 100644 --- a/services/api/internal/domain/attachment/errors.go +++ b/services/api/internal/domain/attachment/errors.go @@ -29,4 +29,16 @@ var ( // ErrTaskNotInProject is returned when the referenced task does not belong // to the project specified in the request URL. ErrTaskNotInProject = errors.New("task does not belong to the specified project") + + // ErrAvatarTooLarge is returned when an avatar upload exceeds MaxAvatarUploadSize. + ErrAvatarTooLarge = errors.New("avatar file exceeds the maximum allowed size") + // ErrAvatarContentTypeInvalid is returned when an avatar upload's content + // type is not in AvatarContentTypes. + ErrAvatarContentTypeInvalid = errors.New("avatar content type must be image/png, image/jpeg, image/webp, or image/gif") + // ErrAvatarDecodeFailed is returned when the uploaded bytes cannot be + // decoded as an image of one of the accepted content types. + ErrAvatarDecodeFailed = errors.New("uploaded file is not a valid image") + // ErrAvatarOwnerMismatch is returned when a file being completed does not + // belong to the claimed avatar owner (storage key prefix mismatch). + ErrAvatarOwnerMismatch = errors.New("file does not belong to the specified avatar owner") ) diff --git a/services/api/internal/domain/doc/activity.go b/services/api/internal/domain/doc/activity.go index 222bdf32..1ad4022e 100644 --- a/services/api/internal/domain/doc/activity.go +++ b/services/api/internal/domain/doc/activity.go @@ -42,11 +42,15 @@ type Activity struct { ActorID *uuid.UUID // nil when the actor account has been removed ActorName string // denormalised full name (populated on read) ActorUsername string // denormalised username (populated on read) - ActivityType ActivityType - Content json.RawMessage - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time // non-nil for soft-deleted comments + // ActorAvatarKey/ActorAvatarThumbKey are the actor's avatar object-storage + // keys (populated on read). Both nil when the actor has no avatar. + ActorAvatarKey *string + ActorAvatarThumbKey *string + ActivityType ActivityType + Content json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time // non-nil for soft-deleted comments } // FieldChange records a single before/after value for doc.updated events. diff --git a/services/api/internal/domain/project/entity.go b/services/api/internal/domain/project/entity.go index caf25775..4dd9a601 100644 --- a/services/api/internal/domain/project/entity.go +++ b/services/api/internal/domain/project/entity.go @@ -15,7 +15,12 @@ type Project struct { TaskIDPrefix string IsPublic bool Settings map[string]any - CreatedBy *uuid.UUID - CreatedAt time.Time - DeletedAt *time.Time // non-nil = soft-deleted + // AvatarKey and AvatarThumbKey are object-storage keys for the two + // server-generated avatar variants (256x256 full, 64x64 thumb). Both nil + // when no avatar has been uploaded. See attachmentdom.AvatarService. + AvatarKey *string + AvatarThumbKey *string + CreatedBy *uuid.UUID + CreatedAt time.Time + DeletedAt *time.Time // non-nil = soft-deleted } diff --git a/services/api/internal/domain/project/member.go b/services/api/internal/domain/project/member.go index d9cf11b5..c74d3358 100644 --- a/services/api/internal/domain/project/member.go +++ b/services/api/internal/domain/project/member.go @@ -23,6 +23,20 @@ type ProjectMember struct { AgentID *uuid.UUID AgentName string AgentHandle string + // AgentType/AgentLLMProvider/AgentACPProvider mirror the agent's own + // fields (agentdom.Agent) — used by the frontend to pick a default + // provider-logo avatar when the agent has no custom avatar uploaded. + // Only meaningful when IsAgent() is true. + AgentType string // "llm" | "acp" + AgentLLMProvider string + AgentACPProvider *string + // Avatar object-storage keys, populated by JOIN from whichever of + // users/agents backs this member (see IsAgent). Both nil when the + // backing user/agent has no avatar uploaded. + UserAvatarKey *string + UserAvatarThumbKey *string + AgentAvatarKey *string + AgentAvatarThumbKey *string } // IsAgent returns true if this member is an AI agent. diff --git a/services/api/internal/domain/project/service.go b/services/api/internal/domain/project/service.go index 1b771b1a..85fffa36 100644 --- a/services/api/internal/domain/project/service.go +++ b/services/api/internal/domain/project/service.go @@ -4,6 +4,8 @@ import ( "context" "github.com/google/uuid" + + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" ) // CreateProjectInput carries fields required to create a new project. @@ -44,4 +46,12 @@ type ProjectService interface { Create(ctx context.Context, in CreateProjectInput) (*Project, error) Update(ctx context.Context, id uuid.UUID, in UpdateProjectInput) (*Project, error) Delete(ctx context.Context, id uuid.UUID) error + + // InitiateAvatarUpload starts an avatar upload for the project and + // returns a presigned upload session. + InitiateAvatarUpload(ctx context.Context, projectID uuid.UUID, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) + // CompleteAvatarUpload finishes an avatar upload, replacing any previous avatar. + CompleteAvatarUpload(ctx context.Context, projectID, fileID uuid.UUID) (*Project, error) + // RemoveAvatar clears the project's avatar, deleting the underlying objects. + RemoveAvatar(ctx context.Context, projectID uuid.UUID) (*Project, error) } diff --git a/services/api/internal/domain/task/activity.go b/services/api/internal/domain/task/activity.go index 09cc5a5e..bf3fa9d1 100644 --- a/services/api/internal/domain/task/activity.go +++ b/services/api/internal/domain/task/activity.go @@ -65,11 +65,15 @@ type Activity struct { ActorID *uuid.UUID // nil when the actor account has been deleted ActorName string // denormalised full name (populated on read) ActorUsername string // denormalised username (populated on read) - ActivityType ActivityType - Content json.RawMessage - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time // non-nil for soft-deleted comments + // ActorAvatarKey/ActorAvatarThumbKey are the actor's avatar object-storage + // keys (populated on read). Both nil when the actor has no avatar. + ActorAvatarKey *string + ActorAvatarThumbKey *string + ActivityType ActivityType + Content json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time // non-nil for soft-deleted comments } // FieldChange records a single before/after value for task.updated events. diff --git a/services/api/internal/domain/user/entity.go b/services/api/internal/domain/user/entity.go index f666a142..afe7c3f5 100644 --- a/services/api/internal/domain/user/entity.go +++ b/services/api/internal/domain/user/entity.go @@ -25,8 +25,13 @@ type User struct { RoleID uuid.UUID // Role holds the role name populated by a JOIN on global_roles; it is not // stored directly in the users table. - Role string - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time + Role string + // AvatarKey and AvatarThumbKey are object-storage keys for the two + // server-generated avatar variants (256x256 full, 64x64 thumb). Both nil + // when no avatar has been uploaded. See attachmentdom.AvatarService. + AvatarKey *string + AvatarThumbKey *string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time } diff --git a/services/api/internal/domain/user/service.go b/services/api/internal/domain/user/service.go index 35b2af7d..d7531a0e 100644 --- a/services/api/internal/domain/user/service.go +++ b/services/api/internal/domain/user/service.go @@ -4,6 +4,8 @@ import ( "context" "github.com/google/uuid" + + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" ) // CreateInput carries the data needed to create a new user. @@ -51,4 +53,13 @@ type Service interface { // newPassword and clears MustChangePassword. ChangeMyPassword(ctx context.Context, id uuid.UUID, currentPassword, newPassword string) error Delete(ctx context.Context, id uuid.UUID) error + + // InitiateAvatarUpload starts an avatar upload for the user's own + // profile picture and returns a presigned upload session. + InitiateAvatarUpload(ctx context.Context, userID uuid.UUID, fileName, contentType string, fileSize int64) (*attachmentdom.UploadSession, error) + // CompleteAvatarUpload finishes an avatar upload started via + // InitiateAvatarUpload, replacing any previous avatar. + CompleteAvatarUpload(ctx context.Context, userID, fileID uuid.UUID) (*User, error) + // RemoveAvatar clears the user's avatar, deleting the underlying objects. + RemoveAvatar(ctx context.Context, userID uuid.UUID) (*User, error) } diff --git a/services/api/internal/platform/storage/s3.go b/services/api/internal/platform/storage/s3.go index 3a31294c..effdbca5 100644 --- a/services/api/internal/platform/storage/s3.go +++ b/services/api/internal/platform/storage/s3.go @@ -1,9 +1,11 @@ package storage import ( + "bytes" "context" "errors" "fmt" + "io" "strings" "time" @@ -232,6 +234,38 @@ func (c *S3Client) DeleteObject(ctx context.Context, bucket, key string) error { return nil } +// GetObject downloads an object's full contents into memory. +func (c *S3Client) GetObject(ctx context.Context, bucket, key string) ([]byte, error) { + out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("storage: get object %q: %w", key, err) + } + defer func() { _ = out.Body.Close() }() + + data, err := io.ReadAll(out.Body) + if err != nil { + return nil, fmt.Errorf("storage: read object %q: %w", key, err) + } + return data, nil +} + +// PutObject uploads data directly to the object store from the server. +func (c *S3Client) PutObject(ctx context.Context, bucket, key, contentType string, data []byte) error { + _, err := c.s3.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + ContentType: aws.String(contentType), + Body: bytes.NewReader(data), + }) + if err != nil { + return fmt.Errorf("storage: put object %q: %w", key, err) + } + return nil +} + // EnsureBucket creates the bucket if it does not already exist. func (c *S3Client) EnsureBucket(ctx context.Context, bucket string) error { _, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}) diff --git a/services/api/internal/platform/storage/storage.go b/services/api/internal/platform/storage/storage.go index c156734d..be123af8 100644 --- a/services/api/internal/platform/storage/storage.go +++ b/services/api/internal/platform/storage/storage.go @@ -58,6 +58,17 @@ type Client interface { // EnsureBucket creates the bucket if it does not already exist. EnsureBucket(ctx context.Context, bucket string) error + + // GetObject downloads an object's full contents. Intended for small + // objects the server needs to process itself (e.g. re-encoding an + // uploaded avatar) — not for client-facing downloads, which should use + // PresignGetObject instead. + GetObject(ctx context.Context, bucket, key string) ([]byte, error) + + // PutObject uploads data directly from the server (as opposed to a + // client uploading via a presigned URL from PresignPutObject). + // Intended for small, server-generated objects (e.g. a resized avatar). + PutObject(ctx context.Context, bucket, key, contentType string, data []byte) error } // MultipartThreshold is the minimum file size (in bytes) at which the service diff --git a/services/api/internal/repository/postgres/agent_repository.go b/services/api/internal/repository/postgres/agent_repository.go index 6853c745..ee8c9024 100644 --- a/services/api/internal/repository/postgres/agent_repository.go +++ b/services/api/internal/repository/postgres/agent_repository.go @@ -27,7 +27,8 @@ type agentRecord struct { GlobalRoleID *string `db:"global_role_id"` Name string `db:"name"` Handle string `db:"handle"` - AvatarURL *string `db:"avatar_url"` + AvatarKey *string `db:"avatar_key"` + AvatarThumbKey *string `db:"avatar_thumb_key"` AgentType string `db:"agent_type"` LLMProvider string `db:"llm_provider"` LLMModel string `db:"llm_model"` @@ -147,7 +148,7 @@ func NewAgentRepository(db *sqlx.DB) *AgentRepository { return &AgentRepository{db: db} } -const agentSelectColsBase = `a.id, a.project_id, a.agent_scope, a.global_role_id, a.name, a.handle, a.avatar_url, a.agent_type, a.llm_provider, a.llm_model, +const agentSelectColsBase = `a.id, a.project_id, a.agent_scope, a.global_role_id, a.name, a.handle, a.avatar_key, a.avatar_thumb_key, a.agent_type, a.llm_provider, a.llm_model, a.llm_api_key_secret, a.llm_base_url, a.acp_provider, a.acp_command, a.acp_bridge_token_hash, a.mcp_api_key_hash, a.system_prompt, a.max_iterations, a.timeout_minutes, a.git_committer_name, a.git_committer_email, a.created_by, a.created_at, a.updated_at, a.deleted_at` @@ -363,12 +364,12 @@ func (r *AgentRepository) CreateAgent(ctx context.Context, a *agentdom.Agent) er return err } _, err = r.db.ExecContext(ctx, ` - INSERT INTO agents (id, project_id, name, handle, avatar_url, agent_type, llm_provider, llm_model, + INSERT INTO agents (id, project_id, name, handle, avatar_key, avatar_thumb_key, agent_type, llm_provider, llm_model, llm_api_key_secret, llm_base_url, acp_provider, acp_command, system_prompt, max_iterations, timeout_minutes, git_committer_name, git_committer_email, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)`, - rec.ID, rec.ProjectID, rec.Name, rec.Handle, rec.AvatarURL, rec.AgentType, + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`, + rec.ID, rec.ProjectID, rec.Name, rec.Handle, rec.AvatarKey, rec.AvatarThumbKey, rec.AgentType, rec.LLMProvider, rec.LLMModel, rec.LLMAPIKeySecret, rec.LLMBaseURL, rec.ACPProvider, rec.ACPCommand, rec.SystemPrompt, @@ -389,13 +390,13 @@ func (r *AgentRepository) UpdateAgent(ctx context.Context, a *agentdom.Agent) er return WithTx(ctx, r.db, func(tx *sqlx.Tx) error { _, err := tx.ExecContext(ctx, ` UPDATE agents SET - name=$1, handle=$2, avatar_url=$3, llm_provider=$4, llm_model=$5, llm_base_url=$6, - acp_provider=$7, acp_command=$8, - system_prompt=$9, - max_iterations=$10, timeout_minutes=$11, - git_committer_name=$12, git_committer_email=$13, global_role_id=$14, updated_at=$15 - WHERE id=$16`, - a.Name, a.Handle, a.AvatarURL, a.LLMProvider, a.LLMModel, a.LLMBaseURL, + name=$1, handle=$2, avatar_key=$3, avatar_thumb_key=$4, llm_provider=$5, llm_model=$6, llm_base_url=$7, + acp_provider=$8, acp_command=$9, + system_prompt=$10, + max_iterations=$11, timeout_minutes=$12, + git_committer_name=$13, git_committer_email=$14, global_role_id=$15, updated_at=$16 + WHERE id=$17`, + a.Name, a.Handle, a.AvatarKey, a.AvatarThumbKey, a.LLMProvider, a.LLMModel, a.LLMBaseURL, rec.ACPProvider, rec.ACPCommand, a.SystemPrompt, a.MaxIterations, a.TimeoutMinutes, @@ -471,12 +472,12 @@ func (r *AgentRepository) CreateAgentWithMembership(ctx context.Context, a *agen return err } _, err = tx.ExecContext(ctx, ` - INSERT INTO agents (id, project_id, name, handle, avatar_url, agent_type, llm_provider, llm_model, + INSERT INTO agents (id, project_id, name, handle, avatar_key, avatar_thumb_key, agent_type, llm_provider, llm_model, llm_api_key_secret, llm_base_url, acp_provider, acp_command, system_prompt, max_iterations, timeout_minutes, git_committer_name, git_committer_email, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)`, - rec.ID, rec.ProjectID, rec.Name, rec.Handle, rec.AvatarURL, rec.AgentType, + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`, + rec.ID, rec.ProjectID, rec.Name, rec.Handle, rec.AvatarKey, rec.AvatarThumbKey, rec.AgentType, rec.LLMProvider, rec.LLMModel, rec.LLMAPIKeySecret, rec.LLMBaseURL, rec.ACPProvider, rec.ACPCommand, rec.SystemPrompt, @@ -539,12 +540,12 @@ func (r *AgentRepository) CreateGlobalAgent(ctx context.Context, a *agentdom.Age return err } _, err = r.db.ExecContext(ctx, ` - INSERT INTO agents (id, project_id, agent_scope, global_role_id, name, handle, avatar_url, agent_type, llm_provider, llm_model, + INSERT INTO agents (id, project_id, agent_scope, global_role_id, name, handle, avatar_key, avatar_thumb_key, agent_type, llm_provider, llm_model, llm_api_key_secret, llm_base_url, acp_provider, acp_command, system_prompt, max_iterations, timeout_minutes, git_committer_name, git_committer_email, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)`, - rec.ID, rec.ProjectID, rec.AgentScope, rec.GlobalRoleID, rec.Name, rec.Handle, rec.AvatarURL, rec.AgentType, + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)`, + rec.ID, rec.ProjectID, rec.AgentScope, rec.GlobalRoleID, rec.Name, rec.Handle, rec.AvatarKey, rec.AvatarThumbKey, rec.AgentType, rec.LLMProvider, rec.LLMModel, rec.LLMAPIKeySecret, rec.LLMBaseURL, rec.ACPProvider, rec.ACPCommand, rec.SystemPrompt, @@ -1212,7 +1213,8 @@ func agentFromReadRow(row agentRecord) (*agentdom.Agent, error) { AgentScope: scope, Name: row.Name, Handle: row.Handle, - AvatarURL: row.AvatarURL, + AvatarKey: row.AvatarKey, + AvatarThumbKey: row.AvatarThumbKey, AgentType: row.AgentType, LLMProvider: row.LLMProvider, LLMModel: row.LLMModel, @@ -1281,7 +1283,8 @@ func agentToRecord(a *agentdom.Agent) (agentRecord, error) { AgentScope: scope, Name: a.Name, Handle: a.Handle, - AvatarURL: a.AvatarURL, + AvatarKey: a.AvatarKey, + AvatarThumbKey: a.AvatarThumbKey, AgentType: agentType, LLMProvider: a.LLMProvider, LLMModel: a.LLMModel, diff --git a/services/api/internal/repository/postgres/document_repository.go b/services/api/internal/repository/postgres/document_repository.go index f82c467b..f02d46f7 100644 --- a/services/api/internal/repository/postgres/document_repository.go +++ b/services/api/internal/repository/postgres/document_repository.go @@ -66,8 +66,10 @@ type docActivityRecord struct { DeletedAt *time.Time `db:"deleted_at"` // Joined from project_members + users. - ActorFullName *string `db:"actor_full_name"` - ActorUsername *string `db:"actor_username"` + ActorFullName *string `db:"actor_full_name"` + ActorUsername *string `db:"actor_username"` + ActorAvatarKey *string `db:"actor_avatar_key"` + ActorAvatarThumbKey *string `db:"actor_avatar_thumb_key"` } // ============================================================================= @@ -227,6 +229,8 @@ func activityFromDocRecord(r docActivityRecord) *docdom.Activity { if r.ActorUsername != nil { a.ActorUsername = *r.ActorUsername } + a.ActorAvatarKey = r.ActorAvatarKey + a.ActorAvatarThumbKey = r.ActorAvatarThumbKey return a } @@ -438,7 +442,9 @@ func (r *DocumentRepository) DeleteRecentSnapshotsExcept(ctx context.Context, do const docActivityJoinSQL = ` SELECT da.id, da.document_id, da.actor_id, da.activity_type, da.content, da.created_at, da.updated_at, da.deleted_at, COALESCE(u.full_name, ag.name) AS actor_full_name, - COALESCE(u.username, ag.handle) AS actor_username + COALESCE(u.username, ag.handle) AS actor_username, + COALESCE(u.avatar_key, ag.avatar_key) AS actor_avatar_key, + COALESCE(u.avatar_thumb_key, ag.avatar_thumb_key) AS actor_avatar_thumb_key FROM doc_activities da LEFT JOIN project_members pm ON pm.id = da.actor_id LEFT JOIN users u ON u.id = pm.user_id diff --git a/services/api/internal/repository/postgres/project_repository.go b/services/api/internal/repository/postgres/project_repository.go index eefb8ade..da7cacfd 100644 --- a/services/api/internal/repository/postgres/project_repository.go +++ b/services/api/internal/repository/postgres/project_repository.go @@ -17,15 +17,17 @@ import ( // --- sqlx models ------------------------------------------------------------ type projectRecord struct { - ID string `db:"id"` - Name string `db:"name"` - Description string `db:"description"` - TaskIDPrefix string `db:"task_id_prefix"` - IsPublic bool `db:"is_public"` - Settings []byte `db:"settings"` - CreatedBy *string `db:"created_by"` - CreatedAt time.Time `db:"created_at"` - DeletedAt *time.Time `db:"deleted_at"` + ID string `db:"id"` + Name string `db:"name"` + Description string `db:"description"` + TaskIDPrefix string `db:"task_id_prefix"` + IsPublic bool `db:"is_public"` + Settings []byte `db:"settings"` + AvatarKey *string `db:"avatar_key"` + AvatarThumbKey *string `db:"avatar_thumb_key"` + CreatedBy *string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + DeletedAt *time.Time `db:"deleted_at"` } type projectRoleRecord struct { @@ -39,19 +41,26 @@ type projectRoleRecord struct { // projectMemberReadRow is the result of the SELECT … JOIN query. type projectMemberReadRow struct { - ID string `db:"id"` - ProjectID string `db:"project_id"` - UserID *string `db:"user_id"` - ProjectRoleID string `db:"project_role_id"` - MemberType string `db:"member_type"` - AgentID *string `db:"agent_id"` - Username string `db:"username"` - FullName string `db:"full_name"` - RoleName string `db:"role_name"` - AgentName string `db:"agent_name"` - AgentHandle string `db:"agent_handle"` - CreatedAt time.Time `db:"created_at"` - DeletedAt *time.Time `db:"deleted_at"` + ID string `db:"id"` + ProjectID string `db:"project_id"` + UserID *string `db:"user_id"` + ProjectRoleID string `db:"project_role_id"` + MemberType string `db:"member_type"` + AgentID *string `db:"agent_id"` + Username string `db:"username"` + FullName string `db:"full_name"` + RoleName string `db:"role_name"` + AgentName string `db:"agent_name"` + AgentHandle string `db:"agent_handle"` + UserAvatarKey *string `db:"user_avatar_key"` + UserAvatarThumbKey *string `db:"user_avatar_thumb_key"` + AgentAvatarKey *string `db:"agent_avatar_key"` + AgentAvatarThumbKey *string `db:"agent_avatar_thumb_key"` + AgentType string `db:"agent_type"` + AgentLLMProvider string `db:"agent_llm_provider"` + AgentACPProvider *string `db:"agent_acp_provider"` + CreatedAt time.Time `db:"created_at"` + DeletedAt *time.Time `db:"deleted_at"` } // --- Repository ------------------------------------------------------------- @@ -66,8 +75,8 @@ func NewProjectRepository(db *sqlx.DB) *ProjectRepository { return &ProjectRepository{db: db} } -const projectSelectCols = `id, name, description, task_id_prefix, is_public, settings, created_by, created_at, deleted_at` -const projectSelectColsQualified = `projects.id, projects.name, projects.description, projects.task_id_prefix, projects.is_public, projects.settings, projects.created_by, projects.created_at, projects.deleted_at` +const projectSelectCols = `id, name, description, task_id_prefix, is_public, settings, avatar_key, avatar_thumb_key, created_by, created_at, deleted_at` +const projectSelectColsQualified = `projects.id, projects.name, projects.description, projects.task_id_prefix, projects.is_public, projects.settings, projects.avatar_key, projects.avatar_thumb_key, projects.created_by, projects.created_at, projects.deleted_at` // --- Projects --------------------------------------------------------------- @@ -187,9 +196,11 @@ func (r *ProjectRepository) Update(ctx context.Context, p *projectdom.Project) e } result, err := r.db.ExecContext(ctx, ` - UPDATE projects SET name=$1, description=$2, task_id_prefix=$3, is_public=$4, settings=$5, created_by=$6 - WHERE id=$7`, - p.Name, p.Description, p.TaskIDPrefix, p.IsPublic, settings, createdBy, p.ID.String(), + UPDATE projects SET name=$1, description=$2, task_id_prefix=$3, is_public=$4, settings=$5, + avatar_key=$6, avatar_thumb_key=$7, created_by=$8 + WHERE id=$9`, + p.Name, p.Description, p.TaskIDPrefix, p.IsPublic, settings, + p.AvatarKey, p.AvatarThumbKey, createdBy, p.ID.String(), ) if err != nil { if isUniqueViolation(err) { @@ -335,7 +346,11 @@ func (r *ProjectRepository) CountMembersWithRole(ctx context.Context, roleID uui const projectMemberCols = ` pm.id, pm.project_id, pm.user_id, pm.project_role_id, pm.member_type, pm.agent_id, pm.created_at, COALESCE(u.username, '') AS username, COALESCE(u.full_name, '') AS full_name, pr.role_name, - COALESCE(a.name, '') AS agent_name, COALESCE(a.handle, '') AS agent_handle` + COALESCE(a.name, '') AS agent_name, COALESCE(a.handle, '') AS agent_handle, + u.avatar_key AS user_avatar_key, u.avatar_thumb_key AS user_avatar_thumb_key, + a.avatar_key AS agent_avatar_key, a.avatar_thumb_key AS agent_avatar_thumb_key, + COALESCE(a.agent_type, '') AS agent_type, COALESCE(a.llm_provider, '') AS agent_llm_provider, + a.acp_provider AS agent_acp_provider` // ListMembers returns all active (non-deleted) members of a project enriched with user and role info. func (r *ProjectRepository) ListMembers(ctx context.Context, projectID uuid.UUID) ([]*projectdom.ProjectMember, error) { @@ -603,15 +618,17 @@ func toProjectEntity(rec *projectRecord) (*projectdom.Project, error) { } } return &projectdom.Project{ - ID: id, - Name: rec.Name, - Description: rec.Description, - TaskIDPrefix: rec.TaskIDPrefix, - IsPublic: rec.IsPublic, - Settings: settings, - CreatedBy: createdBy, - CreatedAt: rec.CreatedAt, - DeletedAt: rec.DeletedAt, + ID: id, + Name: rec.Name, + Description: rec.Description, + TaskIDPrefix: rec.TaskIDPrefix, + IsPublic: rec.IsPublic, + Settings: settings, + AvatarKey: rec.AvatarKey, + AvatarThumbKey: rec.AvatarThumbKey, + CreatedBy: createdBy, + CreatedAt: rec.CreatedAt, + DeletedAt: rec.DeletedAt, }, nil } @@ -689,17 +706,24 @@ func toMemberEntity(row *projectMemberReadRow) *projectdom.ProjectMember { projectID, _ := uuid.Parse(row.ProjectID) roleID, _ := uuid.Parse(row.ProjectRoleID) m := &projectdom.ProjectMember{ - ID: id, - ProjectID: projectID, - ProjectRoleID: roleID, - Username: row.Username, - FullName: row.FullName, - RoleName: row.RoleName, - CreatedAt: row.CreatedAt, - DeletedAt: row.DeletedAt, - MemberType: row.MemberType, - AgentName: row.AgentName, - AgentHandle: row.AgentHandle, + ID: id, + ProjectID: projectID, + ProjectRoleID: roleID, + Username: row.Username, + FullName: row.FullName, + RoleName: row.RoleName, + CreatedAt: row.CreatedAt, + DeletedAt: row.DeletedAt, + MemberType: row.MemberType, + AgentName: row.AgentName, + AgentHandle: row.AgentHandle, + UserAvatarKey: row.UserAvatarKey, + UserAvatarThumbKey: row.UserAvatarThumbKey, + AgentAvatarKey: row.AgentAvatarKey, + AgentAvatarThumbKey: row.AgentAvatarThumbKey, + AgentType: row.AgentType, + AgentLLMProvider: row.AgentLLMProvider, + AgentACPProvider: row.AgentACPProvider, } if row.UserID != nil { userID, _ := uuid.Parse(*row.UserID) diff --git a/services/api/internal/repository/postgres/project_repository_test.go b/services/api/internal/repository/postgres/project_repository_test.go index e4564f4c..798b6466 100644 --- a/services/api/internal/repository/postgres/project_repository_test.go +++ b/services/api/internal/repository/postgres/project_repository_test.go @@ -28,6 +28,8 @@ func openProjectRepoTestDB(t *testing.T) *sqlx.DB { task_id_prefix TEXT NOT NULL DEFAULT '', is_public INTEGER NOT NULL DEFAULT 0, settings BLOB NOT NULL DEFAULT '{}', + avatar_key TEXT, + avatar_thumb_key TEXT, created_by TEXT, created_at DATETIME, deleted_at DATETIME diff --git a/services/api/internal/repository/postgres/task_activity_repository.go b/services/api/internal/repository/postgres/task_activity_repository.go index 8e4caf0f..165d3467 100644 --- a/services/api/internal/repository/postgres/task_activity_repository.go +++ b/services/api/internal/repository/postgres/task_activity_repository.go @@ -26,8 +26,10 @@ type taskActivityRecord struct { DeletedAt *time.Time `db:"deleted_at"` // Joined from the project_members + users tables. - ActorFullName *string `db:"actor_full_name"` - ActorUsername *string `db:"actor_username"` + ActorFullName *string `db:"actor_full_name"` + ActorUsername *string `db:"actor_username"` + ActorAvatarKey *string `db:"actor_avatar_key"` + ActorAvatarThumbKey *string `db:"actor_avatar_thumb_key"` } // --- Repository struct ------------------------------------------------------- @@ -64,6 +66,8 @@ func activityFromRecord(r taskActivityRecord) *taskdom.Activity { if r.ActorUsername != nil { a.ActorUsername = *r.ActorUsername } + a.ActorAvatarKey = r.ActorAvatarKey + a.ActorAvatarThumbKey = r.ActorAvatarThumbKey return a } @@ -73,7 +77,9 @@ const taskActivityJoinSQL = ` SELECT ta.id, ta.task_id, ta.actor_id, ta.activity_type, ta.content, ta.created_at, ta.updated_at, ta.deleted_at, COALESCE(u.full_name, ag.name) AS actor_full_name, - COALESCE(u.username, ag.handle) AS actor_username + COALESCE(u.username, ag.handle) AS actor_username, + COALESCE(u.avatar_key, ag.avatar_key) AS actor_avatar_key, + COALESCE(u.avatar_thumb_key, ag.avatar_thumb_key) AS actor_avatar_thumb_key FROM task_activities ta LEFT JOIN project_members pm ON pm.id = ta.actor_id LEFT JOIN users u ON u.id = pm.user_id diff --git a/services/api/internal/repository/postgres/user_repository.go b/services/api/internal/repository/postgres/user_repository.go index 1f3670fe..6c8de135 100644 --- a/services/api/internal/repository/postgres/user_repository.go +++ b/services/api/internal/repository/postgres/user_repository.go @@ -38,6 +38,8 @@ type userReadRow struct { RoleID string `db:"role_id"` RoleName string `db:"role_name"` MustChangePassword bool `db:"must_change_password"` + AvatarKey *string `db:"avatar_key"` + AvatarThumbKey *string `db:"avatar_thumb_key"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` DeletedAt *time.Time `db:"deleted_at"` @@ -45,7 +47,7 @@ type userReadRow struct { // userReadCols and userReadJoin are shared by all read queries. const ( - userReadCols = `users.id, users.username, users.password_hash, users.full_name, users.role_id, users.must_change_password, users.created_at, users.updated_at, users.deleted_at, gr.name AS role_name` + userReadCols = `users.id, users.username, users.password_hash, users.full_name, users.role_id, users.must_change_password, users.avatar_key, users.avatar_thumb_key, users.created_at, users.updated_at, users.deleted_at, gr.name AS role_name` userReadJoin = `JOIN global_roles gr ON gr.id = users.role_id` ) @@ -167,10 +169,10 @@ func (r *UserRepository) Create(ctx context.Context, u *userdom.User) error { func (r *UserRepository) Update(ctx context.Context, u *userdom.User) error { _, err := r.db.ExecContext(ctx, ` UPDATE users SET username = $1, password_hash = $2, full_name = $3, role_id = $4, - must_change_password = $5, updated_at = $6, deleted_at = $7 - WHERE id = $8`, + must_change_password = $5, avatar_key = $6, avatar_thumb_key = $7, updated_at = $8, deleted_at = $9 + WHERE id = $10`, u.Username, u.PasswordHash, u.FullName, u.RoleID.String(), - u.MustChangePassword, u.UpdatedAt, u.DeletedAt, u.ID.String(), + u.MustChangePassword, u.AvatarKey, u.AvatarThumbKey, u.UpdatedAt, u.DeletedAt, u.ID.String(), ) if err != nil { return fmt.Errorf("user repo: update: %w", err) @@ -201,6 +203,8 @@ func rowToEntity(row *userReadRow) *userdom.User { RoleID: roleID, Role: row.RoleName, MustChangePassword: row.MustChangePassword, + AvatarKey: row.AvatarKey, + AvatarThumbKey: row.AvatarThumbKey, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, DeletedAt: row.DeletedAt, diff --git a/services/api/internal/repository/postgres/user_repository_test.go b/services/api/internal/repository/postgres/user_repository_test.go index a7fafda3..fd0677d1 100644 --- a/services/api/internal/repository/postgres/user_repository_test.go +++ b/services/api/internal/repository/postgres/user_repository_test.go @@ -39,6 +39,8 @@ func openUserRepoTestDB(t *testing.T) (*sqlx.DB, uuid.UUID) { full_name TEXT NOT NULL, role_id TEXT NOT NULL, must_change_password INTEGER NOT NULL DEFAULT 0, + avatar_key TEXT, + avatar_thumb_key TEXT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME diff --git a/services/api/internal/service/agent/agent_service.go b/services/api/internal/service/agent/agent_service.go index 722441e4..87f4d8b2 100644 --- a/services/api/internal/service/agent/agent_service.go +++ b/services/api/internal/service/agent/agent_service.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" + "errors" "fmt" "regexp" "strings" @@ -14,6 +15,7 @@ import ( "github.com/google/uuid" agentdom "github.com/Paca-AI/api/internal/domain/agent" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" plugindom "github.com/Paca-AI/api/internal/domain/plugin" "github.com/Paca-AI/api/internal/events" "github.com/Paca-AI/api/internal/platform/messaging" @@ -38,6 +40,7 @@ type Service struct { publisher *messaging.Publisher pluginRepo pluginFinder encryptor *secret.Encryptor + avatarSvc attachmentdom.AvatarService } // New returns a configured agent service. @@ -51,6 +54,12 @@ func (s *Service) WithEncryptor(enc *secret.Encryptor) *Service { return s } +// WithAvatarService configures avatar upload support. +func (s *Service) WithAvatarService(svc attachmentdom.AvatarService) *Service { + s.avatarSvc = svc + return s +} + // encryptKey encrypts plaintext if an encryptor is configured; otherwise returns plaintext unchanged. func (s *Service) encryptKey(plaintext string) (string, error) { if s.encryptor == nil || plaintext == "" { @@ -643,6 +652,129 @@ func (s *Service) GenerateGlobalAgentMCPKey(ctx context.Context, agentID uuid.UU return plaintext, nil } +// ErrAvatarServiceRequired indicates a missing AvatarService dependency when +// an avatar-upload path is invoked. +var ErrAvatarServiceRequired = errors.New("agent svc: avatar service required") + +// InitiateAvatarUpload starts an avatar upload for a project-scoped agent. +func (s *Service) InitiateAvatarUpload(ctx context.Context, projectID, agentID uuid.UUID, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + if _, err := s.GetAgent(ctx, projectID, agentID); err != nil { + return nil, err + } + return s.avatarSvc.InitiateAvatarUpload(ctx, attachmentdom.AvatarUploadInput{ + OwnerKind: attachmentdom.AvatarOwnerAgent, + OwnerID: agentID, + FileName: fileName, + ContentType: contentType, + FileSize: fileSize, + UploadedBy: uploadedBy, + }) +} + +// CompleteAvatarUpload finishes an avatar upload for a project-scoped agent. +func (s *Service) CompleteAvatarUpload(ctx context.Context, projectID, agentID, fileID uuid.UUID) (*agentdom.Agent, error) { + a, err := s.GetAgent(ctx, projectID, agentID) + if err != nil { + return nil, err + } + return s.completeAvatarUpload(ctx, a, fileID) +} + +// RemoveAvatar clears a project-scoped agent's avatar. +func (s *Service) RemoveAvatar(ctx context.Context, projectID, agentID uuid.UUID) (*agentdom.Agent, error) { + a, err := s.GetAgent(ctx, projectID, agentID) + if err != nil { + return nil, err + } + return s.removeAvatar(ctx, a) +} + +// InitiateGlobalAvatarUpload is InitiateAvatarUpload's global-agent sibling. +func (s *Service) InitiateGlobalAvatarUpload(ctx context.Context, agentID uuid.UUID, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + if _, err := s.GetGlobalAgent(ctx, agentID); err != nil { + return nil, err + } + return s.avatarSvc.InitiateAvatarUpload(ctx, attachmentdom.AvatarUploadInput{ + OwnerKind: attachmentdom.AvatarOwnerAgent, + OwnerID: agentID, + FileName: fileName, + ContentType: contentType, + FileSize: fileSize, + UploadedBy: uploadedBy, + }) +} + +// CompleteGlobalAvatarUpload is CompleteAvatarUpload's global-agent sibling. +func (s *Service) CompleteGlobalAvatarUpload(ctx context.Context, agentID, fileID uuid.UUID) (*agentdom.Agent, error) { + a, err := s.GetGlobalAgent(ctx, agentID) + if err != nil { + return nil, err + } + return s.completeAvatarUpload(ctx, a, fileID) +} + +// RemoveGlobalAvatar is RemoveAvatar's global-agent sibling. +func (s *Service) RemoveGlobalAvatar(ctx context.Context, agentID uuid.UUID) (*agentdom.Agent, error) { + a, err := s.GetGlobalAgent(ctx, agentID) + if err != nil { + return nil, err + } + return s.removeAvatar(ctx, a) +} + +// completeAvatarUpload is the shared tail of CompleteAvatarUpload and +// CompleteGlobalAvatarUpload once the agent has been loaded and its scope +// verified by the caller. +func (s *Service) completeAvatarUpload(ctx context.Context, a *agentdom.Agent, fileID uuid.UUID) (*agentdom.Agent, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + keys, err := s.avatarSvc.CompleteAvatarUpload(ctx, attachmentdom.AvatarCompleteInput{ + OwnerKind: attachmentdom.AvatarOwnerAgent, + OwnerID: a.ID, + FileID: fileID, + }) + if err != nil { + return nil, err + } + + oldKey, oldThumbKey := a.AvatarKey, a.AvatarThumbKey + a.AvatarKey = &keys.Key + a.AvatarThumbKey = &keys.ThumbKey + if err := s.repo.UpdateAgent(ctx, a); err != nil { + return nil, err + } + + s.avatarSvc.DeleteAvatarObjects(ctx, oldKey, oldThumbKey) + return a, nil +} + +// removeAvatar is the shared tail of RemoveAvatar and RemoveGlobalAvatar +// once the agent has been loaded and its scope verified by the caller. +func (s *Service) removeAvatar(ctx context.Context, a *agentdom.Agent) (*agentdom.Agent, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + oldKey, oldThumbKey := a.AvatarKey, a.AvatarThumbKey + if oldKey == nil && oldThumbKey == nil { + return a, nil + } + a.AvatarKey = nil + a.AvatarThumbKey = nil + if err := s.repo.UpdateAgent(ctx, a); err != nil { + return nil, err + } + + s.avatarSvc.DeleteAvatarObjects(ctx, oldKey, oldThumbKey) + return a, nil +} + // requireNonACPAgent rejects MCP server / skill / environment variable // mutations targeting an ACP-type agent. ACP agents run entirely in the // user's own local CLI via paca-acp-bridge; services/ai-agent's diff --git a/services/api/internal/service/attachment/avatar_service.go b/services/api/internal/service/attachment/avatar_service.go new file mode 100644 index 00000000..d6cf7e64 --- /dev/null +++ b/services/api/internal/service/attachment/avatar_service.go @@ -0,0 +1,214 @@ +package attachmentsvc + +import ( + "bytes" + "fmt" + "image" + _ "image/gif" // register GIF decoder with image.Decode + _ "image/jpeg" // register JPEG decoder with image.Decode + "image/png" + "strings" + "time" + + "context" + + "github.com/google/uuid" + "golang.org/x/image/draw" + "golang.org/x/image/webp" + + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" +) + +const ( + // avatarFullSize is the pixel width/height of the "full" derived avatar + // variant — large enough for any current header-sized display (biggest + // today is ~56px) at retina density with headroom to spare. + avatarFullSize = 256 + // avatarThumbSize is the pixel width/height of the "thumb" derived + // avatar variant, used everywhere avatars render small (chips, avatar + // stacks, activity feed, mention dropdown). + avatarThumbSize = 64 + // avatarURLTTL is how long a presigned avatar GET URL remains valid. + // Generous relative to typical page/query staleTime so a cached page + // never shows a broken image before its next refetch. + avatarURLTTL = 1 * time.Hour +) + +// InitiateAvatarUpload creates a pending File record and returns a presigned +// single-part upload session. Avatars are capped well under +// storage.MultipartThreshold, so multipart is never needed here. +func (s *Service) InitiateAvatarUpload(ctx context.Context, in attachmentdom.AvatarUploadInput) (*attachmentdom.UploadSession, error) { + if !attachmentdom.AvatarContentTypes[in.ContentType] { + return nil, attachmentdom.ErrAvatarContentTypeInvalid + } + if in.FileSize <= 0 { + return nil, attachmentdom.ErrFileSizeZero + } + if in.FileSize > attachmentdom.MaxAvatarUploadSize { + return nil, attachmentdom.ErrAvatarTooLarge + } + + fileID := uuid.New() + storageKey := avatarOwnerPrefix(in.OwnerKind, in.OwnerID) + "/" + fileID.String() + "/upload" + + now := time.Now() + f := &attachmentdom.File{ + ID: fileID, + StorageKey: storageKey, + Bucket: s.bucket, + FileName: in.FileName, + ContentType: in.ContentType, + FileSize: in.FileSize, + UploadStatus: attachmentdom.UploadStatusPending, + UploadedBy: &in.UploadedBy, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.CreateFile(ctx, f); err != nil { + return nil, fmt.Errorf("attachment svc: create avatar file: %w", err) + } + + uploadURL, err := s.store.PresignPutObject(ctx, s.bucket, storageKey, in.ContentType, presignedUploadTTL) + if err != nil { + return nil, fmt.Errorf("attachment svc: presign put for avatar: %w", err) + } + + return &attachmentdom.UploadSession{FileID: fileID, UploadURL: uploadURL}, nil +} + +// CompleteAvatarUpload downloads the raw upload, re-encodes it into "full" +// and "thumb" PNG variants, uploads both, and discards the raw upload (both +// the object and its File row) — nothing ever serves the client-uploaded +// bytes directly, only the two derived variants this method produces. +func (s *Service) CompleteAvatarUpload(ctx context.Context, in attachmentdom.AvatarCompleteInput) (*attachmentdom.AvatarKeys, error) { + f, err := s.repo.FindFileByID(ctx, in.FileID) + if err != nil { + return nil, err + } + if f.UploadStatus != attachmentdom.UploadStatusPending { + return nil, attachmentdom.ErrUploadNotPending + } + if !strings.HasPrefix(f.StorageKey, avatarOwnerPrefix(in.OwnerKind, in.OwnerID)+"/") { + return nil, attachmentdom.ErrAvatarOwnerMismatch + } + + bucket := f.Bucket + if bucket == "" { + bucket = s.bucket + } + + raw, err := s.store.GetObject(ctx, bucket, f.StorageKey) + if err != nil { + return nil, fmt.Errorf("attachment svc: download avatar upload: %w", err) + } + + img, err := decodeAvatarImage(raw, f.ContentType) + if err != nil { + return nil, attachmentdom.ErrAvatarDecodeFailed + } + square := cropToSquare(img) + + fullBytes, err := resizeEncodePNG(square, avatarFullSize) + if err != nil { + return nil, fmt.Errorf("attachment svc: encode avatar full: %w", err) + } + thumbBytes, err := resizeEncodePNG(square, avatarThumbSize) + if err != nil { + return nil, fmt.Errorf("attachment svc: encode avatar thumb: %w", err) + } + + prefix := avatarOwnerPrefix(in.OwnerKind, in.OwnerID) + "/" + in.FileID.String() + keys := &attachmentdom.AvatarKeys{ + Key: prefix + "/full.png", + ThumbKey: prefix + "/thumb.png", + } + + if err := s.store.PutObject(ctx, s.bucket, keys.Key, "image/png", fullBytes); err != nil { + return nil, fmt.Errorf("attachment svc: upload avatar full: %w", err) + } + if err := s.store.PutObject(ctx, s.bucket, keys.ThumbKey, "image/png", thumbBytes); err != nil { + return nil, fmt.Errorf("attachment svc: upload avatar thumb: %w", err) + } + + // Best-effort cleanup: the raw upload is now fully superseded by the two + // derived variants and must never be served, but a failure to delete it + // (or its bookkeeping row) shouldn't fail the request — it's a harmless + // orphan at worst, matching the DeleteTaskAttachment precedent of + // intentionally keeping/not-chasing `files` rows once they're no longer + // referenced. + _ = s.store.DeleteObject(ctx, bucket, f.StorageKey) + _ = s.repo.DeleteFile(ctx, in.FileID) + + return keys, nil +} + +// ResolveAvatarURL returns a presigned GET URL for key, or nil if key is nil +// or empty (no avatar set). +func (s *Service) ResolveAvatarURL(ctx context.Context, key *string) (*string, error) { + if key == nil || *key == "" { + return nil, nil + } + url, err := s.store.PresignGetObject(ctx, s.bucket, *key, avatarURLTTL, "") + if err != nil { + return nil, fmt.Errorf("attachment svc: presign avatar url: %w", err) + } + return &url, nil +} + +// DeleteAvatarObjects best-effort deletes the given storage keys. nil/empty +// keys are skipped; delete errors are intentionally swallowed (see the +// cleanup comment in CompleteAvatarUpload) so a stray already-gone object +// never blocks the caller's avatar replace/removal. +func (s *Service) DeleteAvatarObjects(ctx context.Context, keys ...*string) { + for _, k := range keys { + if k == nil || *k == "" { + continue + } + _ = s.store.DeleteObject(ctx, s.bucket, *k) + } +} + +// avatarOwnerPrefix returns the storage-key prefix (no trailing slash) all +// of an owner's avatar objects live under. +func avatarOwnerPrefix(kind attachmentdom.AvatarOwnerKind, ownerID uuid.UUID) string { + return fmt.Sprintf("avatars/%s/%s", kind, ownerID.String()) +} + +// decodeAvatarImage decodes raw image bytes. WebP needs its own decoder +// (golang.org/x/image/webp, decode-only) — png/jpeg/gif decoders are +// registered with the stdlib image.Decode dispatcher via blank imports +// above. +func decodeAvatarImage(data []byte, contentType string) (image.Image, error) { + if contentType == "image/webp" { + return webp.Decode(bytes.NewReader(data)) + } + img, _, err := image.Decode(bytes.NewReader(data)) + return img, err +} + +// cropToSquare returns the center square crop of img as a fresh RGBA image. +func cropToSquare(img image.Image) *image.RGBA { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + side := w + if h < side { + side = h + } + origin := image.Pt(b.Min.X+(w-side)/2, b.Min.Y+(h-side)/2) + dst := image.NewRGBA(image.Rect(0, 0, side, side)) + draw.Draw(dst, dst.Bounds(), img, origin, draw.Src) + return dst +} + +// resizeEncodePNG scales square (already square) down to size x size using +// a high-quality resampler and PNG-encodes the result. +func resizeEncodePNG(square image.Image, size int) ([]byte, error) { + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.CatmullRom.Scale(dst, dst.Bounds(), square, square.Bounds(), draw.Over, nil) + + var buf bytes.Buffer + if err := png.Encode(&buf, dst); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/services/api/internal/service/attachment/avatar_service_test.go b/services/api/internal/service/attachment/avatar_service_test.go new file mode 100644 index 00000000..03b24df7 --- /dev/null +++ b/services/api/internal/service/attachment/avatar_service_test.go @@ -0,0 +1,79 @@ +package attachmentsvc + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" +) + +func solidImage(w, h int, c color.Color) image.Image { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, c) + } + } + return img +} + +func TestCropToSquare(t *testing.T) { + tests := []struct { + name string + w, h int + wantLen int + }{ + {"wider than tall", 200, 100, 100}, + {"taller than wide", 100, 200, 100}, + {"already square", 150, 150, 150}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + square := cropToSquare(solidImage(tt.w, tt.h, color.White)) + b := square.Bounds() + if b.Dx() != tt.wantLen || b.Dy() != tt.wantLen { + t.Fatalf("cropToSquare(%dx%d): got %dx%d, want %dx%d", tt.w, tt.h, b.Dx(), b.Dy(), tt.wantLen, tt.wantLen) + } + }) + } +} + +func TestResizeEncodePNG(t *testing.T) { + square := solidImage(300, 300, color.RGBA{R: 10, G: 20, B: 30, A: 255}) + + for _, size := range []int{avatarFullSize, avatarThumbSize} { + data, err := resizeEncodePNG(square, size) + if err != nil { + t.Fatalf("resizeEncodePNG(%d): %v", size, err) + } + decoded, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode resized PNG: %v", err) + } + b := decoded.Bounds() + if b.Dx() != size || b.Dy() != size { + t.Fatalf("resizeEncodePNG(%d): output is %dx%d, want %dx%d", size, b.Dx(), b.Dy(), size, size) + } + } +} + +func TestDecodeAvatarImage_PNG(t *testing.T) { + var buf bytes.Buffer + if err := png.Encode(&buf, solidImage(10, 10, color.Black)); err != nil { + t.Fatalf("encode fixture PNG: %v", err) + } + img, err := decodeAvatarImage(buf.Bytes(), "image/png") + if err != nil { + t.Fatalf("decodeAvatarImage: %v", err) + } + if b := img.Bounds(); b.Dx() != 10 || b.Dy() != 10 { + t.Fatalf("decoded image is %dx%d, want 10x10", b.Dx(), b.Dy()) + } +} + +func TestDecodeAvatarImage_InvalidBytes(t *testing.T) { + if _, err := decodeAvatarImage([]byte("not an image"), "image/png"); err == nil { + t.Fatal("expected decodeAvatarImage to reject non-image bytes, got nil error") + } +} diff --git a/services/api/internal/service/project/cached_service.go b/services/api/internal/service/project/cached_service.go index 16ad762b..d4bbafe4 100644 --- a/services/api/internal/service/project/cached_service.go +++ b/services/api/internal/service/project/cached_service.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" projectdom "github.com/Paca-AI/api/internal/domain/project" "github.com/Paca-AI/api/internal/platform/cache" ) @@ -138,6 +139,35 @@ func (c *CachedService) Delete(ctx context.Context, id uuid.UUID) error { return nil } +// InitiateAvatarUpload delegates directly to the underlying service (nothing to cache). +func (c *CachedService) InitiateAvatarUpload(ctx context.Context, projectID uuid.UUID, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) { + return c.svc.InitiateAvatarUpload(ctx, projectID, fileName, contentType, fileSize, uploadedBy) +} + +// CompleteAvatarUpload delegates to the underlying service and invalidates the project cache entry. +func (c *CachedService) CompleteAvatarUpload(ctx context.Context, projectID, fileID uuid.UUID) (*projectdom.Project, error) { + p, err := c.svc.CompleteAvatarUpload(ctx, projectID, fileID) + if err != nil { + return nil, err + } + if err := c.st.Delete(ctx, projectKey(projectID)); err != nil { + c.log.WarnContext(ctx, "cache: CompleteAvatarUpload delete", "err", err) + } + return p, nil +} + +// RemoveAvatar delegates to the underlying service and invalidates the project cache entry. +func (c *CachedService) RemoveAvatar(ctx context.Context, projectID uuid.UUID) (*projectdom.Project, error) { + p, err := c.svc.RemoveAvatar(ctx, projectID) + if err != nil { + return nil, err + } + if err := c.st.Delete(ctx, projectKey(projectID)); err != nil { + c.log.WarnContext(ctx, "cache: RemoveAvatar delete", "err", err) + } + return p, nil +} + // --- Members ----------------------------------------------------------------- // ListMembers returns all members of a project, reading from cache when diff --git a/services/api/internal/service/project/cached_service_test.go b/services/api/internal/service/project/cached_service_test.go index a6997180..213cd13f 100644 --- a/services/api/internal/service/project/cached_service_test.go +++ b/services/api/internal/service/project/cached_service_test.go @@ -14,6 +14,7 @@ import ( "github.com/google/uuid" "github.com/redis/go-redis/v9" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" projectdom "github.com/Paca-AI/api/internal/domain/project" "github.com/Paca-AI/api/internal/platform/cache" projectsvc "github.com/Paca-AI/api/internal/service/project" @@ -117,6 +118,16 @@ func (s *stubProjectSvc) Delete(ctx context.Context, id uuid.UUID) error { return nil } +func (s *stubProjectSvc) InitiateAvatarUpload(context.Context, uuid.UUID, string, string, int64, uuid.UUID) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{}, nil +} +func (s *stubProjectSvc) CompleteAvatarUpload(context.Context, uuid.UUID, uuid.UUID) (*projectdom.Project, error) { + return nil, projectdom.ErrNotFound +} +func (s *stubProjectSvc) RemoveAvatar(context.Context, uuid.UUID) (*projectdom.Project, error) { + return nil, projectdom.ErrNotFound +} + func (s *stubProjectSvc) ListMembers(ctx context.Context, projectID uuid.UUID) ([]*projectdom.ProjectMember, error) { s.listMembersCalls++ if s.listMembers != nil { diff --git a/services/api/internal/service/project/project_service.go b/services/api/internal/service/project/project_service.go index e78f721d..247e33c0 100644 --- a/services/api/internal/service/project/project_service.go +++ b/services/api/internal/service/project/project_service.go @@ -3,6 +3,7 @@ package projectsvc import ( "context" + "errors" "regexp" "strings" "time" @@ -11,6 +12,7 @@ import ( "github.com/google/uuid" agentdom "github.com/Paca-AI/api/internal/domain/agent" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" projectdom "github.com/Paca-AI/api/internal/domain/project" taskdom "github.com/Paca-AI/api/internal/domain/task" "github.com/Paca-AI/api/internal/platform/authz" @@ -97,9 +99,10 @@ type agentLookup interface { // Service is the concrete implementation of projectdom.Service. type Service struct { - repo projectdom.Repository - taskRepo taskBootstrapper - agents agentLookup + repo projectdom.Repository + taskRepo taskBootstrapper + agents agentLookup + avatarSvc attachmentdom.AvatarService } // New returns a configured project service. @@ -107,6 +110,16 @@ func New(repo projectdom.Repository, taskRepo taskBootstrapper, agents agentLook return &Service{repo: repo, taskRepo: taskRepo, agents: agents} } +// WithAvatarService configures avatar upload support. +func (s *Service) WithAvatarService(svc attachmentdom.AvatarService) *Service { + s.avatarSvc = svc + return s +} + +// ErrAvatarServiceRequired indicates a missing AvatarService dependency when +// an avatar-upload path is invoked. +var ErrAvatarServiceRequired = errors.New("project svc: avatar service required") + // List returns a page of projects and the total count. func (s *Service) List(ctx context.Context, page, pageSize int) ([]*projectdom.Project, int64, error) { if page < 1 { @@ -352,6 +365,78 @@ func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { return s.repo.Delete(ctx, id) } +// InitiateAvatarUpload starts an avatar upload for the project. +func (s *Service) InitiateAvatarUpload(ctx context.Context, projectID uuid.UUID, fileName, contentType string, fileSize int64, uploadedBy uuid.UUID) (*attachmentdom.UploadSession, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + if _, err := s.repo.FindByID(ctx, projectID); err != nil { + return nil, err + } + return s.avatarSvc.InitiateAvatarUpload(ctx, attachmentdom.AvatarUploadInput{ + OwnerKind: attachmentdom.AvatarOwnerProject, + OwnerID: projectID, + FileName: fileName, + ContentType: contentType, + FileSize: fileSize, + UploadedBy: uploadedBy, + }) +} + +// CompleteAvatarUpload finishes an avatar upload, replacing any previous avatar. +func (s *Service) CompleteAvatarUpload(ctx context.Context, projectID, fileID uuid.UUID) (*projectdom.Project, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + p, err := s.repo.FindByID(ctx, projectID) + if err != nil { + return nil, err + } + + keys, err := s.avatarSvc.CompleteAvatarUpload(ctx, attachmentdom.AvatarCompleteInput{ + OwnerKind: attachmentdom.AvatarOwnerProject, + OwnerID: projectID, + FileID: fileID, + }) + if err != nil { + return nil, err + } + + oldKey, oldThumbKey := p.AvatarKey, p.AvatarThumbKey + p.AvatarKey = &keys.Key + p.AvatarThumbKey = &keys.ThumbKey + if err := s.repo.Update(ctx, p); err != nil { + return nil, err + } + + s.avatarSvc.DeleteAvatarObjects(ctx, oldKey, oldThumbKey) + return p, nil +} + +// RemoveAvatar clears the project's avatar, deleting the underlying objects. +func (s *Service) RemoveAvatar(ctx context.Context, projectID uuid.UUID) (*projectdom.Project, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + p, err := s.repo.FindByID(ctx, projectID) + if err != nil { + return nil, err + } + + oldKey, oldThumbKey := p.AvatarKey, p.AvatarThumbKey + if oldKey == nil && oldThumbKey == nil { + return p, nil + } + p.AvatarKey = nil + p.AvatarThumbKey = nil + if err := s.repo.Update(ctx, p); err != nil { + return nil, err + } + + s.avatarSvc.DeleteAvatarObjects(ctx, oldKey, oldThumbKey) + return p, nil +} + func cloneSettings(in map[string]any) map[string]any { if in == nil { return map[string]any{} diff --git a/services/api/internal/service/user/user_service.go b/services/api/internal/service/user/user_service.go index 9056291d..ea122b02 100644 --- a/services/api/internal/service/user/user_service.go +++ b/services/api/internal/service/user/user_service.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "golang.org/x/crypto/bcrypt" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" globalroledom "github.com/Paca-AI/api/internal/domain/globalrole" userdom "github.com/Paca-AI/api/internal/domain/user" "github.com/Paca-AI/api/internal/platform/authz" @@ -31,12 +32,17 @@ type Service struct { repo userdom.Repository globalPermissionReader GlobalPermissionReader roleRepo RoleByNameFinder + avatarSvc attachmentdom.AvatarService } // ErrRoleResolverRequired indicates a missing role resolver dependency when a // mutating path requires Role -> RoleID resolution. var ErrRoleResolverRequired = errors.New("user svc: role resolver required") +// ErrAvatarServiceRequired indicates a missing AvatarService dependency when +// an avatar-upload path is invoked. +var ErrAvatarServiceRequired = errors.New("user svc: avatar service required") + // New returns a configured user Service. // Pass optional GlobalPermissionReader and RoleByNameFinder as variadic args. func New(repo userdom.Repository, opts ...any) *Service { @@ -52,6 +58,12 @@ func New(repo userdom.Repository, opts ...any) *Service { return s } +// WithAvatarService configures avatar upload support. +func (s *Service) WithAvatarService(svc attachmentdom.AvatarService) *Service { + s.avatarSvc = svc + return s +} + // GetByID returns a user by primary key. func (s *Service) GetByID(ctx context.Context, id uuid.UUID) (*userdom.User, error) { return s.repo.FindByID(ctx, id) @@ -257,3 +269,74 @@ func (s *Service) ChangeMyPassword(ctx context.Context, id uuid.UUID, currentPas func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { return s.repo.Delete(ctx, id) } + +// InitiateAvatarUpload starts an avatar upload for the user's own profile picture. +func (s *Service) InitiateAvatarUpload(ctx context.Context, userID uuid.UUID, fileName, contentType string, fileSize int64) (*attachmentdom.UploadSession, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + return s.avatarSvc.InitiateAvatarUpload(ctx, attachmentdom.AvatarUploadInput{ + OwnerKind: attachmentdom.AvatarOwnerUser, + OwnerID: userID, + FileName: fileName, + ContentType: contentType, + FileSize: fileSize, + UploadedBy: userID, + }) +} + +// CompleteAvatarUpload finishes an avatar upload, replacing any previous avatar. +func (s *Service) CompleteAvatarUpload(ctx context.Context, userID, fileID uuid.UUID) (*userdom.User, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + u, err := s.repo.FindByID(ctx, userID) + if err != nil { + return nil, err + } + + keys, err := s.avatarSvc.CompleteAvatarUpload(ctx, attachmentdom.AvatarCompleteInput{ + OwnerKind: attachmentdom.AvatarOwnerUser, + OwnerID: userID, + FileID: fileID, + }) + if err != nil { + return nil, err + } + + oldKey, oldThumbKey := u.AvatarKey, u.AvatarThumbKey + u.AvatarKey = &keys.Key + u.AvatarThumbKey = &keys.ThumbKey + u.UpdatedAt = time.Now() + if err := s.repo.Update(ctx, u); err != nil { + return nil, err + } + + s.avatarSvc.DeleteAvatarObjects(ctx, oldKey, oldThumbKey) + return u, nil +} + +// RemoveAvatar clears the user's avatar, deleting the underlying objects. +func (s *Service) RemoveAvatar(ctx context.Context, userID uuid.UUID) (*userdom.User, error) { + if s.avatarSvc == nil { + return nil, ErrAvatarServiceRequired + } + u, err := s.repo.FindByID(ctx, userID) + if err != nil { + return nil, err + } + + oldKey, oldThumbKey := u.AvatarKey, u.AvatarThumbKey + if oldKey == nil && oldThumbKey == nil { + return u, nil + } + u.AvatarKey = nil + u.AvatarThumbKey = nil + u.UpdatedAt = time.Now() + if err := s.repo.Update(ctx, u); err != nil { + return nil, err + } + + s.avatarSvc.DeleteAvatarObjects(ctx, oldKey, oldThumbKey) + return u, nil +} diff --git a/services/api/internal/transport/http/dto/agent_dto.go b/services/api/internal/transport/http/dto/agent_dto.go index ae5a25d0..b0ed0fb2 100644 --- a/services/api/internal/transport/http/dto/agent_dto.go +++ b/services/api/internal/transport/http/dto/agent_dto.go @@ -18,14 +18,18 @@ import ( // global-scope agent (AgentScope == "global"); GlobalRoleID is only ever // set for a global-scope agent. type AgentResponse struct { - ID uuid.UUID `json:"id"` - ProjectID *uuid.UUID `json:"project_id,omitempty"` - AgentScope string `json:"agent_scope"` - GlobalRoleID *uuid.UUID `json:"global_role_id,omitempty"` - MemberID *uuid.UUID `json:"member_id,omitempty"` - Name string `json:"name"` - Handle string `json:"handle"` + ID uuid.UUID `json:"id"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` + AgentScope string `json:"agent_scope"` + GlobalRoleID *uuid.UUID `json:"global_role_id,omitempty"` + MemberID *uuid.UUID `json:"member_id,omitempty"` + Name string `json:"name"` + Handle string `json:"handle"` + // AvatarURL/AvatarThumbURL are presigned GET URLs, populated by the + // handler (not this mapper) via attachmentdom.AvatarService — nil when + // no avatar has been uploaded. AvatarURL *string `json:"avatar_url,omitempty"` + AvatarThumbURL *string `json:"avatar_thumb_url,omitempty"` AgentType string `json:"agent_type"` LLMProvider string `json:"llm_provider"` LLMModel string `json:"llm_model"` @@ -147,7 +151,6 @@ func AgentFromEntity(a *agentdom.Agent) AgentResponse { MemberID: a.MemberID, Name: a.Name, Handle: a.Handle, - AvatarURL: a.AvatarURL, AgentType: a.AgentType, LLMProvider: a.LLMProvider, LLMModel: a.LLMModel, diff --git a/services/api/internal/transport/http/dto/avatar_dto.go b/services/api/internal/transport/http/dto/avatar_dto.go new file mode 100644 index 00000000..9b42fbdf --- /dev/null +++ b/services/api/internal/transport/http/dto/avatar_dto.go @@ -0,0 +1,11 @@ +package dto + +import "github.com/google/uuid" + +// CompleteAvatarUploadRequest is the body for POST .../avatar/complete-upload. +// Unlike CompleteUploadRequest (task/doc attachments), avatars are always +// single-part (capped well under the multipart threshold), so there is no +// upload_id/parts to carry. +type CompleteAvatarUploadRequest struct { + FileID uuid.UUID `json:"file_id" binding:"required"` +} diff --git a/services/api/internal/transport/http/dto/doc_dto.go b/services/api/internal/transport/http/dto/doc_dto.go index 4c85c781..7f361e43 100644 --- a/services/api/internal/transport/http/dto/doc_dto.go +++ b/services/api/internal/transport/http/dto/doc_dto.go @@ -174,16 +174,21 @@ type UpdateDocCommentRequest struct { // DocActivityResponse is the public representation of a doc activity entry. type DocActivityResponse struct { - ID uuid.UUID `json:"id"` - DocumentID uuid.UUID `json:"document_id"` - ActorID *uuid.UUID `json:"actor_id,omitempty"` - ActorName string `json:"actor_name,omitempty"` - ActorUsername string `json:"actor_username,omitempty"` - ActivityType string `json:"activity_type"` - Content json.RawMessage `json:"content"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + ID uuid.UUID `json:"id"` + DocumentID uuid.UUID `json:"document_id"` + ActorID *uuid.UUID `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + ActorUsername string `json:"actor_username,omitempty"` + // ActorAvatarURL/ActorAvatarThumbURL are presigned GET URLs, populated by + // the handler (not this mapper) via attachmentdom.AvatarService — nil + // when the actor has no avatar. + ActorAvatarURL *string `json:"actor_avatar_url,omitempty"` + ActorAvatarThumbURL *string `json:"actor_avatar_thumb_url,omitempty"` + ActivityType string `json:"activity_type"` + Content json.RawMessage `json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // DocActivityFromEntity maps a domain Activity to a DocActivityResponse DTO. diff --git a/services/api/internal/transport/http/dto/project_dto.go b/services/api/internal/transport/http/dto/project_dto.go index 26375992..bac18c16 100644 --- a/services/api/internal/transport/http/dto/project_dto.go +++ b/services/api/internal/transport/http/dto/project_dto.go @@ -36,8 +36,13 @@ type ProjectResponse struct { TaskIDPrefix string `json:"task_id_prefix"` IsPublic bool `json:"is_public"` Settings map[string]any `json:"settings"` - CreatedBy *uuid.UUID `json:"created_by,omitempty"` - CreatedAt time.Time `json:"created_at"` + // AvatarURL/AvatarThumbURL are presigned GET URLs, populated by the + // handler (not this mapper) via attachmentdom.AvatarService — nil when + // no avatar has been uploaded. + AvatarURL *string `json:"avatar_url,omitempty"` + AvatarThumbURL *string `json:"avatar_thumb_url,omitempty"` + CreatedBy *uuid.UUID `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` } // WorkspaceStatsResponse is the public representation of workspace-level diff --git a/services/api/internal/transport/http/dto/project_member_dto.go b/services/api/internal/transport/http/dto/project_member_dto.go index 2c5de936..b230fa61 100644 --- a/services/api/internal/transport/http/dto/project_member_dto.go +++ b/services/api/internal/transport/http/dto/project_member_dto.go @@ -36,6 +36,18 @@ type ProjectMemberResponse struct { AgentID *uuid.UUID `json:"agent_id,omitempty"` AgentName string `json:"agent_name,omitempty"` AgentHandle string `json:"agent_handle,omitempty"` + // AvatarURL/AvatarThumbURL are presigned GET URLs for whichever of + // user/agent backs this member, populated by the handler (not this + // mapper) via attachmentdom.AvatarService — nil when no avatar has been + // uploaded. + AvatarURL *string `json:"avatar_url,omitempty"` + AvatarThumbURL *string `json:"avatar_thumb_url,omitempty"` + // AgentType/AgentLLMProvider/AgentACPProvider mirror the agent's own + // fields — only meaningful when MemberType == "agent". The frontend uses + // these to pick a default provider-logo avatar when AvatarURL is unset. + AgentType string `json:"agent_type,omitempty"` + AgentLLMProvider string `json:"agent_llm_provider,omitempty"` + AgentACPProvider *string `json:"agent_acp_provider,omitempty"` } // ProjectMemberFromEntity maps a domain ProjectMember to a ProjectMemberResponse DTO. @@ -50,6 +62,10 @@ func ProjectMemberFromEntity(m *projectdom.ProjectMember) ProjectMemberResponse AgentID: m.AgentID, AgentName: m.AgentName, AgentHandle: m.AgentHandle, + + AgentType: m.AgentType, + AgentLLMProvider: m.AgentLLMProvider, + AgentACPProvider: m.AgentACPProvider, } if m.IsAgent() { // For agent members, populate username/full_name from agent fields so @@ -62,3 +78,13 @@ func ProjectMemberFromEntity(m *projectdom.ProjectMember) ProjectMemberResponse } return resp } + +// MemberAvatarKeys returns the avatar object-storage keys backing m — +// whichever of the user/agent pair actually applies — for the handler to +// resolve into presigned URLs. +func MemberAvatarKeys(m *projectdom.ProjectMember) (key, thumbKey *string) { + if m.IsAgent() { + return m.AgentAvatarKey, m.AgentAvatarThumbKey + } + return m.UserAvatarKey, m.UserAvatarThumbKey +} diff --git a/services/api/internal/transport/http/dto/task_dto.go b/services/api/internal/transport/http/dto/task_dto.go index 05b69feb..00e268ce 100644 --- a/services/api/internal/transport/http/dto/task_dto.go +++ b/services/api/internal/transport/http/dto/task_dto.go @@ -482,15 +482,20 @@ func CustomFieldDefinitionFromEntity(f *taskdom.CustomFieldDefinition) CustomFie // ActivityResponse is the public representation of a task activity entry. type ActivityResponse struct { - ID uuid.UUID `json:"id"` - TaskID uuid.UUID `json:"task_id"` - ActorID *uuid.UUID `json:"actor_id,omitempty"` - ActorName string `json:"actor_name"` - ActorUsername string `json:"actor_username"` - ActivityType taskdom.ActivityType `json:"activity_type"` - Content json.RawMessage `json:"content"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + TaskID uuid.UUID `json:"task_id"` + ActorID *uuid.UUID `json:"actor_id,omitempty"` + ActorName string `json:"actor_name"` + ActorUsername string `json:"actor_username"` + // ActorAvatarURL/ActorAvatarThumbURL are presigned GET URLs, populated by + // the handler (not this mapper) via attachmentdom.AvatarService — nil + // when the actor has no avatar. + ActorAvatarURL *string `json:"actor_avatar_url,omitempty"` + ActorAvatarThumbURL *string `json:"actor_avatar_thumb_url,omitempty"` + ActivityType taskdom.ActivityType `json:"activity_type"` + Content json.RawMessage `json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ActivityFromEntity maps a domain Activity to an ActivityResponse DTO. diff --git a/services/api/internal/transport/http/dto/user_dto.go b/services/api/internal/transport/http/dto/user_dto.go index 99cc4c9a..c6b37f74 100644 --- a/services/api/internal/transport/http/dto/user_dto.go +++ b/services/api/internal/transport/http/dto/user_dto.go @@ -50,7 +50,12 @@ type UserResponse struct { FullName string `json:"full_name"` Role string `json:"role"` MustChangePassword bool `json:"must_change_password"` - CreatedAt time.Time `json:"created_at"` + // AvatarURL/AvatarThumbURL are presigned GET URLs, populated by the + // handler (not this mapper) via attachmentdom.AvatarService — nil when + // no avatar has been uploaded. + AvatarURL *string `json:"avatar_url,omitempty"` + AvatarThumbURL *string `json:"avatar_thumb_url,omitempty"` + CreatedAt time.Time `json:"created_at"` } // PagedUsersResponse wraps a list of users with pagination metadata. diff --git a/services/api/internal/transport/http/handler/agent_handler.go b/services/api/internal/transport/http/handler/agent_handler.go index d977e7bc..46d8327c 100644 --- a/services/api/internal/transport/http/handler/agent_handler.go +++ b/services/api/internal/transport/http/handler/agent_handler.go @@ -16,6 +16,7 @@ import ( "github.com/Paca-AI/api/internal/apierr" agentdom "github.com/Paca-AI/api/internal/domain/agent" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" projectdom "github.com/Paca-AI/api/internal/domain/project" taskdom "github.com/Paca-AI/api/internal/domain/task" "github.com/Paca-AI/api/internal/platform/authz" @@ -51,6 +52,7 @@ type AgentHandler struct { activityRec agentActivityRecorder memberRepo projectdom.MemberRepository globalPermReader agentGlobalPermissionReader + avatarSvc attachmentdom.AvatarService } // NewAgentHandler returns an AgentHandler wired to the agent service. @@ -90,6 +92,23 @@ func (h *AgentHandler) WithGlobalPermissionReader(reader agentGlobalPermissionRe return h } +// WithAvatarService configures avatar URL resolution for AgentResponse. +func (h *AgentHandler) WithAvatarService(svc attachmentdom.AvatarService) *AgentHandler { + h.avatarSvc = svc + return h +} + +// toAgentResponse maps ag to an AgentResponse and, if an AvatarService is +// configured, resolves its avatar keys into presigned display URLs. +func (h *AgentHandler) toAgentResponse(ctx context.Context, ag *agentdom.Agent) dto.AgentResponse { + resp := dto.AgentFromEntity(ag) + if h.avatarSvc != nil { + resp.AvatarURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, ag.AvatarKey) + resp.AvatarThumbURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, ag.AvatarThumbKey) + } + return resp +} + // callerUserID extracts the authenticated human user's ID from the // request's JWT claims. Used by the global-chat handlers, which have no // project context and therefore no project_members.id to resolve (unlike @@ -153,7 +172,7 @@ func (h *AgentHandler) ListAgents(w http.ResponseWriter, r *http.Request) { } resp := make([]dto.AgentResponse, 0, len(agents)) for _, a := range agents { - resp = append(resp, dto.AgentFromEntity(a)) + resp = append(resp, h.toAgentResponse(r.Context(), a)) } presenter.OK(w, r, map[string]any{"items": resp}) } @@ -175,7 +194,7 @@ func (h *AgentHandler) GetAgent(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.OK(w, r, dto.AgentFromEntity(a)) + presenter.OK(w, r, h.toAgentResponse(r.Context(), a)) } // CreateAgent handles POST /projects/:projectId/agents. @@ -257,7 +276,7 @@ func (h *AgentHandler) CreateAgent(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.Created(w, r, dto.AgentFromEntity(a)) + presenter.Created(w, r, h.toAgentResponse(r.Context(), a)) } // UpdateAgent handles PATCH /projects/:projectId/agents/:agentId. @@ -296,7 +315,7 @@ func (h *AgentHandler) UpdateAgent(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.OK(w, r, dto.AgentFromEntity(a)) + presenter.OK(w, r, h.toAgentResponse(r.Context(), a)) } // DeleteAgent handles DELETE /projects/:projectId/agents/:agentId. @@ -329,7 +348,7 @@ func (h *AgentHandler) ListGlobalAgents(w http.ResponseWriter, r *http.Request) } resp := make([]dto.AgentResponse, 0, len(agents)) for _, a := range agents { - resp = append(resp, dto.AgentFromEntity(a)) + resp = append(resp, h.toAgentResponse(r.Context(), a)) } presenter.OK(w, r, map[string]any{"items": resp}) } @@ -346,7 +365,7 @@ func (h *AgentHandler) GetGlobalAgent(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.OK(w, r, dto.AgentFromEntity(a)) + presenter.OK(w, r, h.toAgentResponse(r.Context(), a)) } // CreateGlobalAgent handles POST /admin/agents. @@ -415,7 +434,7 @@ func (h *AgentHandler) CreateGlobalAgent(w http.ResponseWriter, r *http.Request) presenter.Error(w, r, err) return } - presenter.Created(w, r, dto.AgentFromEntity(a)) + presenter.Created(w, r, h.toAgentResponse(r.Context(), a)) } // UpdateGlobalAgent handles PATCH /admin/agents/:agentId. @@ -450,7 +469,7 @@ func (h *AgentHandler) UpdateGlobalAgent(w http.ResponseWriter, r *http.Request) presenter.Error(w, r, err) return } - presenter.OK(w, r, dto.AgentFromEntity(a)) + presenter.OK(w, r, h.toAgentResponse(r.Context(), a)) } // DeleteGlobalAgent handles DELETE /admin/agents/:agentId. @@ -1709,6 +1728,157 @@ func (h *AgentHandler) GenerateGlobalAgentMCPKey(w http.ResponseWriter, r *http. presenter.OK(w, r, dto.GenerateMCPAgentKeyResponse{Token: token}) } +// --- Avatar ------------------------------------------------------------------- + +// InitiateAvatarUpload handles POST /projects/:projectId/agents/:agentId/avatar/initiate-upload. +func (h *AgentHandler) InitiateAvatarUpload(w http.ResponseWriter, r *http.Request) { + projectID, err := parseProjectID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + agentID, err := parseParamUUID(r, "agentId") + if err != nil { + presenter.Error(w, r, err) + return + } + claims := middleware.ClaimsFrom(r) + if claims == nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "unauthenticated")) + return + } + uploaderID, err := uuid.Parse(claims.Subject) + if err != nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "invalid subject in token")) + return + } + + var req dto.InitiateUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + session, err := h.svc.InitiateAvatarUpload(r.Context(), projectID, agentID, req.FileName, req.ContentType, req.FileSize, uploaderID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.Created(w, r, dto.UploadSessionFromDomain(session)) +} + +// CompleteAvatarUpload handles POST /projects/:projectId/agents/:agentId/avatar/complete-upload. +func (h *AgentHandler) CompleteAvatarUpload(w http.ResponseWriter, r *http.Request) { + projectID, err := parseProjectID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + agentID, err := parseParamUUID(r, "agentId") + if err != nil { + presenter.Error(w, r, err) + return + } + + var req dto.CompleteAvatarUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + a, err := h.svc.CompleteAvatarUpload(r.Context(), projectID, agentID, req.FileID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toAgentResponse(r.Context(), a)) +} + +// DeleteAvatar handles DELETE /projects/:projectId/agents/:agentId/avatar. +func (h *AgentHandler) DeleteAvatar(w http.ResponseWriter, r *http.Request) { + projectID, err := parseProjectID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + agentID, err := parseParamUUID(r, "agentId") + if err != nil { + presenter.Error(w, r, err) + return + } + a, err := h.svc.RemoveAvatar(r.Context(), projectID, agentID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toAgentResponse(r.Context(), a)) +} + +// InitiateGlobalAvatarUpload handles POST /admin/agents/:agentId/avatar/initiate-upload. +func (h *AgentHandler) InitiateGlobalAvatarUpload(w http.ResponseWriter, r *http.Request) { + agentID, err := parseParamUUID(r, "agentId") + if err != nil { + presenter.Error(w, r, err) + return + } + claims := middleware.ClaimsFrom(r) + if claims == nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "unauthenticated")) + return + } + uploaderID, err := uuid.Parse(claims.Subject) + if err != nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "invalid subject in token")) + return + } + + var req dto.InitiateUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + session, err := h.svc.InitiateGlobalAvatarUpload(r.Context(), agentID, req.FileName, req.ContentType, req.FileSize, uploaderID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.Created(w, r, dto.UploadSessionFromDomain(session)) +} + +// CompleteGlobalAvatarUpload handles POST /admin/agents/:agentId/avatar/complete-upload. +func (h *AgentHandler) CompleteGlobalAvatarUpload(w http.ResponseWriter, r *http.Request) { + agentID, err := parseParamUUID(r, "agentId") + if err != nil { + presenter.Error(w, r, err) + return + } + + var req dto.CompleteAvatarUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + a, err := h.svc.CompleteGlobalAvatarUpload(r.Context(), agentID, req.FileID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toAgentResponse(r.Context(), a)) +} + +// DeleteGlobalAvatar handles DELETE /admin/agents/:agentId/avatar. +func (h *AgentHandler) DeleteGlobalAvatar(w http.ResponseWriter, r *http.Request) { + agentID, err := parseParamUUID(r, "agentId") + if err != nil { + presenter.Error(w, r, err) + return + } + a, err := h.svc.RemoveGlobalAvatar(r.Context(), agentID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toAgentResponse(r.Context(), a)) +} + // --- Activity Feed ------------------------------------------------------------ var validActivitySourceTypes = []string{"task", "doc"} diff --git a/services/api/internal/transport/http/handler/agent_handler_test.go b/services/api/internal/transport/http/handler/agent_handler_test.go index 76bd3de4..764740cf 100644 --- a/services/api/internal/transport/http/handler/agent_handler_test.go +++ b/services/api/internal/transport/http/handler/agent_handler_test.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" agentdom "github.com/Paca-AI/api/internal/domain/agent" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" domainauth "github.com/Paca-AI/api/internal/domain/auth" projectdom "github.com/Paca-AI/api/internal/domain/project" "github.com/Paca-AI/api/internal/transport/http/handler" @@ -224,6 +225,24 @@ func (m *mockAgentSvc) StartGlobalChatSession(_ context.Context, _, _ uuid.UUID, func (m *mockAgentSvc) SendGlobalChatMessage(_ context.Context, _, _ uuid.UUID, _ string) (*agentdom.AgentConversation, error) { return &agentdom.AgentConversation{ID: uuid.New()}, nil } +func (m *mockAgentSvc) InitiateAvatarUpload(_ context.Context, _, _ uuid.UUID, _, _ string, _ int64, _ uuid.UUID) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{}, nil +} +func (m *mockAgentSvc) CompleteAvatarUpload(_ context.Context, _, _, _ uuid.UUID) (*agentdom.Agent, error) { + return nil, agentdom.ErrAgentNotFound +} +func (m *mockAgentSvc) RemoveAvatar(_ context.Context, _, _ uuid.UUID) (*agentdom.Agent, error) { + return nil, agentdom.ErrAgentNotFound +} +func (m *mockAgentSvc) InitiateGlobalAvatarUpload(_ context.Context, _ uuid.UUID, _, _ string, _ int64, _ uuid.UUID) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{}, nil +} +func (m *mockAgentSvc) CompleteGlobalAvatarUpload(_ context.Context, _, _ uuid.UUID) (*agentdom.Agent, error) { + return nil, agentdom.ErrAgentNotFound +} +func (m *mockAgentSvc) RemoveGlobalAvatar(_ context.Context, _ uuid.UUID) (*agentdom.Agent, error) { + return nil, agentdom.ErrAgentNotFound +} var _ agentdom.Service = (*mockAgentSvc)(nil) diff --git a/services/api/internal/transport/http/handler/document_handler.go b/services/api/internal/transport/http/handler/document_handler.go index f1d94482..808333b0 100644 --- a/services/api/internal/transport/http/handler/document_handler.go +++ b/services/api/internal/transport/http/handler/document_handler.go @@ -1,6 +1,7 @@ package handler import ( + "context" "encoding/json" "net/http" @@ -8,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/Paca-AI/api/internal/apierr" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" docdom "github.com/Paca-AI/api/internal/domain/doc" "github.com/Paca-AI/api/internal/transport/http/dto" "github.com/Paca-AI/api/internal/transport/http/middleware" @@ -18,6 +20,7 @@ import ( type DocumentHandler struct { svc docdom.Service activitySvc docdom.ActivityService + avatarSvc attachmentdom.AvatarService } // NewDocumentHandler returns a DocumentHandler wired to the doc service and @@ -26,6 +29,25 @@ func NewDocumentHandler(svc docdom.Service, activitySvc docdom.ActivityService) return &DocumentHandler{svc: svc, activitySvc: activitySvc} } +// WithDocAvatarService configures avatar URL resolution for activity +// responses (comments/system events show the actor's avatar). +func (h *DocumentHandler) WithDocAvatarService(svc attachmentdom.AvatarService) *DocumentHandler { + h.avatarSvc = svc + return h +} + +// toDocActivityResponse maps a to a DocActivityResponse and, if an +// AvatarService is configured, resolves the actor's avatar keys into +// presigned display URLs. +func (h *DocumentHandler) toDocActivityResponse(ctx context.Context, a *docdom.Activity) dto.DocActivityResponse { + resp := dto.DocActivityFromEntity(a) + if h.avatarSvc != nil { + resp.ActorAvatarURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, a.ActorAvatarKey) + resp.ActorAvatarThumbURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, a.ActorAvatarThumbKey) + } + return resp +} + // ============================================================================= // Folder endpoints // ============================================================================= @@ -403,7 +425,7 @@ func (h *DocumentHandler) ListActivities(w http.ResponseWriter, r *http.Request) } resp := make([]dto.DocActivityResponse, 0, len(activities)) for _, a := range activities { - resp = append(resp, dto.DocActivityFromEntity(a)) + resp = append(resp, h.toDocActivityResponse(r.Context(), a)) } presenter.OK(w, r, map[string]any{"items": resp}) } @@ -453,7 +475,7 @@ func (h *DocumentHandler) AddComment(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.Created(w, r, dto.DocActivityFromEntity(a)) + presenter.Created(w, r, h.toDocActivityResponse(r.Context(), a)) } // UpdateComment handles PATCH /projects/:projectId/docs/:docId/comments/:commentId. @@ -495,7 +517,7 @@ func (h *DocumentHandler) UpdateComment(w http.ResponseWriter, r *http.Request) presenter.Error(w, r, err) return } - presenter.OK(w, r, dto.DocActivityFromEntity(a)) + presenter.OK(w, r, h.toDocActivityResponse(r.Context(), a)) } // DeleteComment handles DELETE /projects/:projectId/docs/:docId/comments/:commentId. diff --git a/services/api/internal/transport/http/handler/project_handler.go b/services/api/internal/transport/http/handler/project_handler.go index cf5bab3f..5f67f536 100644 --- a/services/api/internal/transport/http/handler/project_handler.go +++ b/services/api/internal/transport/http/handler/project_handler.go @@ -9,6 +9,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/Paca-AI/api/internal/apierr" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" projectdom "github.com/Paca-AI/api/internal/domain/project" sprintdom "github.com/Paca-AI/api/internal/domain/sprint" "github.com/Paca-AI/api/internal/platform/authz" @@ -39,6 +40,7 @@ type ProjectHandler struct { taskTypeSvc taskTypeLister taskSvc taskServiceForStats userSvc userServiceForStats + avatarSvc attachmentdom.AvatarService } // ProjectHandlerOption customizes optional project-handler dependencies. @@ -64,6 +66,16 @@ func WithProjectStatsServices(taskSvc taskServiceForStats, userSvc userServiceFo } } +// WithProjectAvatarService configures avatar URL resolution for member +// responses (ListMembers, AddMember, UpdateMemberRole) and for the +// project's own avatar (ProjectResponse), and enables the project avatar +// upload endpoints. +func WithProjectAvatarService(svc attachmentdom.AvatarService) ProjectHandlerOption { + return func(h *ProjectHandler) { + h.avatarSvc = svc + } +} + // NewProjectHandler returns a ProjectHandler wired to the service and authorizer. func NewProjectHandler(svc projectdom.Service, authorizer *authz.Authorizer, opts ...ProjectHandlerOption) *ProjectHandler { h := &ProjectHandler{svc: svc, authorizer: authorizer} @@ -75,6 +87,17 @@ func NewProjectHandler(svc projectdom.Service, authorizer *authz.Authorizer, opt return h } +// toProjectResponse maps p to a ProjectResponse and, if an AvatarService is +// configured, resolves its avatar keys into presigned display URLs. +func (h *ProjectHandler) toProjectResponse(ctx context.Context, p *projectdom.Project) dto.ProjectResponse { + resp := dto.ProjectFromEntity(p) + if h.avatarSvc != nil { + resp.AvatarURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, p.AvatarKey) + resp.AvatarThumbURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, p.AvatarThumbKey) + } + return resp +} + // ListProjects handles GET /projects. // Users with the global projects.read permission receive all projects. // All other authenticated users receive only the projects they are a member of. @@ -117,7 +140,7 @@ func (h *ProjectHandler) ListProjects(w http.ResponseWriter, r *http.Request) { resp := make([]dto.ProjectResponse, 0, len(projects)) for _, p := range projects { - resp = append(resp, dto.ProjectFromEntity(p)) + resp = append(resp, h.toProjectResponse(r.Context(), p)) } presenter.OK(w, r, map[string]any{"items": resp, "total": total, "page": page, "page_size": pageSize}) } @@ -233,7 +256,7 @@ func (h *ProjectHandler) GetProject(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.OK(w, r, dto.ProjectFromEntity(p)) + presenter.OK(w, r, h.toProjectResponse(r.Context(), p)) } // CreateProject handles POST /projects. @@ -280,7 +303,7 @@ func (h *ProjectHandler) CreateProject(w http.ResponseWriter, r *http.Request) { } } - presenter.Created(w, r, dto.ProjectFromEntity(p)) + presenter.Created(w, r, h.toProjectResponse(r.Context(), p)) } // UpdateProject handles PATCH /projects/:projectId. @@ -307,7 +330,7 @@ func (h *ProjectHandler) UpdateProject(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.OK(w, r, dto.ProjectFromEntity(p)) + presenter.OK(w, r, h.toProjectResponse(r.Context(), p)) } // DeleteProject handles DELETE /projects/:projectId. @@ -324,6 +347,73 @@ func (h *ProjectHandler) DeleteProject(w http.ResponseWriter, r *http.Request) { presenter.OK(w, r, map[string]any{"message": "project deleted"}) } +// InitiateAvatarUpload handles POST /projects/:projectId/avatar/initiate-upload. +func (h *ProjectHandler) InitiateAvatarUpload(w http.ResponseWriter, r *http.Request) { + id, err := parseProjectID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + claims := middleware.ClaimsFrom(r) + if claims == nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "unauthenticated")) + return + } + uploaderID, err := uuid.Parse(claims.Subject) + if err != nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "invalid subject in token")) + return + } + + var req dto.InitiateUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + session, err := h.svc.InitiateAvatarUpload(r.Context(), id, req.FileName, req.ContentType, req.FileSize, uploaderID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.Created(w, r, dto.UploadSessionFromDomain(session)) +} + +// CompleteAvatarUpload handles POST /projects/:projectId/avatar/complete-upload. +func (h *ProjectHandler) CompleteAvatarUpload(w http.ResponseWriter, r *http.Request) { + id, err := parseProjectID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + + var req dto.CompleteAvatarUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + p, err := h.svc.CompleteAvatarUpload(r.Context(), id, req.FileID) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toProjectResponse(r.Context(), p)) +} + +// DeleteAvatar handles DELETE /projects/:projectId/avatar. +func (h *ProjectHandler) DeleteAvatar(w http.ResponseWriter, r *http.Request) { + id, err := parseProjectID(r) + if err != nil { + presenter.Error(w, r, err) + return + } + p, err := h.svc.RemoveAvatar(r.Context(), id) + if err != nil { + presenter.Error(w, r, err) + return + } + presenter.OK(w, r, h.toProjectResponse(r.Context(), p)) +} + // --- helpers ---------------------------------------------------------------- func parseProjectID(r *http.Request) (uuid.UUID, error) { diff --git a/services/api/internal/transport/http/handler/project_handler_test.go b/services/api/internal/transport/http/handler/project_handler_test.go index fbd86924..b6e5b203 100644 --- a/services/api/internal/transport/http/handler/project_handler_test.go +++ b/services/api/internal/transport/http/handler/project_handler_test.go @@ -15,6 +15,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" domainauth "github.com/Paca-AI/api/internal/domain/auth" projectdom "github.com/Paca-AI/api/internal/domain/project" sprintdom "github.com/Paca-AI/api/internal/domain/sprint" @@ -100,6 +101,16 @@ func (m *mockProjectSvc) Delete(ctx context.Context, id uuid.UUID) error { return nil } +func (m *mockProjectSvc) InitiateAvatarUpload(context.Context, uuid.UUID, string, string, int64, uuid.UUID) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{}, nil +} +func (m *mockProjectSvc) CompleteAvatarUpload(context.Context, uuid.UUID, uuid.UUID) (*projectdom.Project, error) { + return nil, projectdom.ErrNotFound +} +func (m *mockProjectSvc) RemoveAvatar(context.Context, uuid.UUID) (*projectdom.Project, error) { + return nil, projectdom.ErrNotFound +} + func (m *mockProjectSvc) ListMembers(ctx context.Context, projectID uuid.UUID) ([]*projectdom.ProjectMember, error) { if m.listMembers != nil { return m.listMembers(ctx, projectID) diff --git a/services/api/internal/transport/http/handler/project_member_handler.go b/services/api/internal/transport/http/handler/project_member_handler.go index 785c8604..e6b9eeab 100644 --- a/services/api/internal/transport/http/handler/project_member_handler.go +++ b/services/api/internal/transport/http/handler/project_member_handler.go @@ -1,6 +1,7 @@ package handler import ( + "context" "net/http" "github.com/go-chi/chi/v5" @@ -13,6 +14,19 @@ import ( "github.com/Paca-AI/api/internal/transport/http/presenter" ) +// toProjectMemberResponse maps m to a ProjectMemberResponse and, if an +// AvatarService is configured, resolves the backing user/agent's avatar +// keys into presigned display URLs. +func (h *ProjectHandler) toProjectMemberResponse(ctx context.Context, m *projectdom.ProjectMember) dto.ProjectMemberResponse { + resp := dto.ProjectMemberFromEntity(m) + if h.avatarSvc != nil { + key, thumbKey := dto.MemberAvatarKeys(m) + resp.AvatarURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, key) + resp.AvatarThumbURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, thumbKey) + } + return resp +} + // ListMembers handles GET /projects/:projectId/members. func (h *ProjectHandler) ListMembers(w http.ResponseWriter, r *http.Request) { id, err := parseProjectID(r) @@ -27,7 +41,7 @@ func (h *ProjectHandler) ListMembers(w http.ResponseWriter, r *http.Request) { } resp := make([]dto.ProjectMemberResponse, 0, len(members)) for _, m := range members { - resp = append(resp, dto.ProjectMemberFromEntity(m)) + resp = append(resp, h.toProjectMemberResponse(r.Context(), m)) } presenter.OK(w, r, resp) } @@ -64,7 +78,7 @@ func (h *ProjectHandler) AddMember(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.Created(w, r, dto.ProjectMemberFromEntity(m)) + presenter.Created(w, r, h.toProjectMemberResponse(r.Context(), m)) } // UpdateMemberRole handles PATCH /projects/:projectId/members/:memberId. @@ -96,7 +110,7 @@ func (h *ProjectHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request presenter.Error(w, r, err) return } - presenter.OK(w, r, dto.ProjectMemberFromEntity(m)) + presenter.OK(w, r, h.toProjectMemberResponse(r.Context(), m)) } // RemoveMember handles DELETE /projects/:projectId/members/:memberId. diff --git a/services/api/internal/transport/http/handler/task_handler.go b/services/api/internal/transport/http/handler/task_handler.go index 322caee3..756810bd 100644 --- a/services/api/internal/transport/http/handler/task_handler.go +++ b/services/api/internal/transport/http/handler/task_handler.go @@ -17,6 +17,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/Paca-AI/api/internal/apierr" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" projectdom "github.com/Paca-AI/api/internal/domain/project" sprintdom "github.com/Paca-AI/api/internal/domain/sprint" taskdom "github.com/Paca-AI/api/internal/domain/task" @@ -34,6 +35,7 @@ type TaskHandler struct { activitySvc taskdom.ActivityService publisher *messaging.Publisher projectSvc projectServiceForAssigned + avatarSvc attachmentdom.AvatarService } // NewTaskHandler returns a TaskHandler wired to the task service, view service, @@ -57,6 +59,25 @@ func WithTaskPublisher(p *messaging.Publisher) TaskHandlerOption { } } +// WithTaskAvatarService configures avatar URL resolution for activity +// responses (comments/system events show the actor's avatar). +func WithTaskAvatarService(svc attachmentdom.AvatarService) TaskHandlerOption { + return func(h *TaskHandler) { + h.avatarSvc = svc + } +} + +// toActivityResponse maps a to an ActivityResponse and, if an AvatarService +// is configured, resolves the actor's avatar keys into presigned display URLs. +func (h *TaskHandler) toActivityResponse(ctx context.Context, a *taskdom.Activity) dto.ActivityResponse { + resp := dto.ActivityFromEntity(a) + if h.avatarSvc != nil { + resp.ActorAvatarURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, a.ActorAvatarKey) + resp.ActorAvatarThumbURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, a.ActorAvatarThumbKey) + } + return resp +} + // assignedToMeMemberLookupConcurrency caps how many per-project ListMembers // lookups ListAssignedToMe issues concurrently while resolving the caller's // member IDs. @@ -1471,7 +1492,7 @@ func (h *TaskHandler) ListTaskActivities(w http.ResponseWriter, r *http.Request) } resp := make([]dto.ActivityResponse, 0, len(activities)) for _, a := range activities { - resp = append(resp, dto.ActivityFromEntity(a)) + resp = append(resp, h.toActivityResponse(r.Context(), a)) } presenter.OK(w, r, map[string]any{"items": resp}) } @@ -1522,7 +1543,7 @@ func (h *TaskHandler) AddComment(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.Created(w, r, dto.ActivityFromEntity(a)) + presenter.Created(w, r, h.toActivityResponse(r.Context(), a)) } // UpdateComment handles PATCH /projects/:projectId/tasks/:taskId/activities/comments/:commentId. @@ -1565,7 +1586,7 @@ func (h *TaskHandler) UpdateComment(w http.ResponseWriter, r *http.Request) { presenter.Error(w, r, err) return } - presenter.OK(w, r, dto.ActivityFromEntity(a)) + presenter.OK(w, r, h.toActivityResponse(r.Context(), a)) } // DeleteComment handles DELETE /projects/:projectId/tasks/:taskId/activities/comments/:commentId. diff --git a/services/api/internal/transport/http/handler/user_handler.go b/services/api/internal/transport/http/handler/user_handler.go index cb402e84..5467300f 100644 --- a/services/api/internal/transport/http/handler/user_handler.go +++ b/services/api/internal/transport/http/handler/user_handler.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/Paca-AI/api/internal/apierr" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" domainuser "github.com/Paca-AI/api/internal/domain/user" "github.com/Paca-AI/api/internal/transport/http/dto" "github.com/Paca-AI/api/internal/transport/http/middleware" @@ -23,8 +24,9 @@ type SessionInvalidator interface { // UserHandler handles user-related endpoints. type UserHandler struct { - svc domainuser.Service - authSvc SessionInvalidator + svc domainuser.Service + authSvc SessionInvalidator + avatarSvc attachmentdom.AvatarService } // NewUserHandler returns a UserHandler wired to the provided user service. @@ -38,6 +40,23 @@ func NewUserHandler(svc domainuser.Service, authSvc ...SessionInvalidator) *User return h } +// WithAvatarService configures avatar URL resolution for UserResponse. +func (h *UserHandler) WithAvatarService(svc attachmentdom.AvatarService) *UserHandler { + h.avatarSvc = svc + return h +} + +// toUserResponse maps u to a UserResponse and, if an AvatarService is +// configured, resolves its avatar keys into presigned display URLs. +func (h *UserHandler) toUserResponse(ctx context.Context, u *domainuser.User) dto.UserResponse { + resp := dto.UserFromEntity(u) + if h.avatarSvc != nil { + resp.AvatarURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, u.AvatarKey) + resp.AvatarThumbURL, _ = h.avatarSvc.ResolveAvatarURL(ctx, u.AvatarThumbKey) + } + return resp +} + // --- Self-service routes --------------------------------------------------- // GetMe handles GET /users/me — returns the caller's own profile. @@ -60,7 +79,7 @@ func (h *UserHandler) GetMe(w http.ResponseWriter, r *http.Request) { return } - presenter.OK(w, r, dto.UserFromEntity(u)) + presenter.OK(w, r, h.toUserResponse(r.Context(), u)) } // UpdateMe handles PATCH /users/me — lets users update their own profile. @@ -90,7 +109,7 @@ func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) { return } - presenter.OK(w, r, dto.UserFromEntity(u)) + presenter.OK(w, r, h.toUserResponse(r.Context(), u)) } // GetMyGlobalPermissions handles GET /users/me/global-permissions. @@ -139,7 +158,7 @@ func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) { items := make([]dto.UserResponse, 0, len(users)) for _, u := range users { - items = append(items, dto.UserFromEntity(u)) + items = append(items, h.toUserResponse(r.Context(), u)) } presenter.OK(w, r, dto.PagedUsersResponse{ @@ -164,7 +183,7 @@ func (h *UserHandler) GetUserByID(w http.ResponseWriter, r *http.Request) { return } - presenter.OK(w, r, dto.UserFromEntity(u)) + presenter.OK(w, r, h.toUserResponse(r.Context(), u)) } // CreateUser handles POST /admin/users — admin-only user creation. @@ -194,7 +213,7 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { return } - presenter.Created(w, r, dto.UserFromEntity(u)) + presenter.Created(w, r, h.toUserResponse(r.Context(), u)) } // AdminUpdateUser handles PATCH /admin/users/:userId — admin update of any user. @@ -219,7 +238,7 @@ func (h *UserHandler) AdminUpdateUser(w http.ResponseWriter, r *http.Request) { return } - presenter.OK(w, r, dto.UserFromEntity(u)) + presenter.OK(w, r, h.toUserResponse(r.Context(), u)) } // DeleteUser handles DELETE /admin/users/:userId. @@ -307,3 +326,81 @@ func (h *UserHandler) ChangeMyPassword(w http.ResponseWriter, r *http.Request) { presenter.NoContent(w) } + +// --- Avatar ------------------------------------------------------------ + +// InitiateAvatarUpload handles POST /users/me/avatar/initiate-upload. +func (h *UserHandler) InitiateAvatarUpload(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFrom(r) + if claims == nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "unauthenticated")) + return + } + id, err := uuid.Parse(claims.Subject) + if err != nil { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid subject claim")) + return + } + + var req dto.InitiateUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + session, err := h.svc.InitiateAvatarUpload(r.Context(), id, req.FileName, req.ContentType, req.FileSize) + if err != nil { + presenter.Error(w, r, err) + return + } + + presenter.Created(w, r, dto.UploadSessionFromDomain(session)) +} + +// CompleteAvatarUpload handles POST /users/me/avatar/complete-upload. +func (h *UserHandler) CompleteAvatarUpload(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFrom(r) + if claims == nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "unauthenticated")) + return + } + id, err := uuid.Parse(claims.Subject) + if err != nil { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid subject claim")) + return + } + + var req dto.CompleteAvatarUploadRequest + if !middleware.BindJSON(w, r, &req) { + return + } + + u, err := h.svc.CompleteAvatarUpload(r.Context(), id, req.FileID) + if err != nil { + presenter.Error(w, r, err) + return + } + + presenter.OK(w, r, h.toUserResponse(r.Context(), u)) +} + +// DeleteAvatar handles DELETE /users/me/avatar. +func (h *UserHandler) DeleteAvatar(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFrom(r) + if claims == nil { + presenter.Error(w, r, apierr.New(apierr.CodeUnauthenticated, "unauthenticated")) + return + } + id, err := uuid.Parse(claims.Subject) + if err != nil { + presenter.Error(w, r, apierr.New(apierr.CodeBadRequest, "invalid subject claim")) + return + } + + u, err := h.svc.RemoveAvatar(r.Context(), id) + if err != nil { + presenter.Error(w, r, err) + return + } + + presenter.OK(w, r, h.toUserResponse(r.Context(), u)) +} diff --git a/services/api/internal/transport/http/handler/user_handler_test.go b/services/api/internal/transport/http/handler/user_handler_test.go index dc29a38d..150ca17d 100644 --- a/services/api/internal/transport/http/handler/user_handler_test.go +++ b/services/api/internal/transport/http/handler/user_handler_test.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" domainuser "github.com/Paca-AI/api/internal/domain/user" "github.com/Paca-AI/api/internal/transport/http/handler" ) @@ -30,6 +31,9 @@ type mockUserSvc struct { resetPassword func(ctx context.Context, id uuid.UUID, newPassword string) error changeMyPassword func(ctx context.Context, id uuid.UUID, currentPassword, newPassword string) error delete func(ctx context.Context, id uuid.UUID) error + initiateAvatarUpload func(ctx context.Context, userID uuid.UUID, fileName, contentType string, fileSize int64) (*attachmentdom.UploadSession, error) + completeAvatarUpload func(ctx context.Context, userID, fileID uuid.UUID) (*domainuser.User, error) + removeAvatar func(ctx context.Context, userID uuid.UUID) (*domainuser.User, error) } func (m *mockUserSvc) GetByID(ctx context.Context, id uuid.UUID) (*domainuser.User, error) { @@ -90,6 +94,25 @@ func (m *mockUserSvc) ChangeMyPassword(ctx context.Context, id uuid.UUID, curren return nil } +func (m *mockUserSvc) InitiateAvatarUpload(ctx context.Context, userID uuid.UUID, fileName, contentType string, fileSize int64) (*attachmentdom.UploadSession, error) { + if m.initiateAvatarUpload != nil { + return m.initiateAvatarUpload(ctx, userID, fileName, contentType, fileSize) + } + return &attachmentdom.UploadSession{}, nil +} +func (m *mockUserSvc) CompleteAvatarUpload(ctx context.Context, userID, fileID uuid.UUID) (*domainuser.User, error) { + if m.completeAvatarUpload != nil { + return m.completeAvatarUpload(ctx, userID, fileID) + } + return nil, domainuser.ErrNotFound +} +func (m *mockUserSvc) RemoveAvatar(ctx context.Context, userID uuid.UUID) (*domainuser.User, error) { + if m.removeAvatar != nil { + return m.removeAvatar(ctx, userID) + } + return nil, domainuser.ErrNotFound +} + // verify mock satisfies the interface at compile time var _ domainuser.Service = (*mockUserSvc)(nil) diff --git a/services/api/internal/transport/http/presenter/response.go b/services/api/internal/transport/http/presenter/response.go index ae5784b5..ece0cca1 100644 --- a/services/api/internal/transport/http/presenter/response.go +++ b/services/api/internal/transport/http/presenter/response.go @@ -242,6 +242,12 @@ func statusAndCodeFor(err error) (int, apierr.Code) { return http.StatusBadRequest, apierr.CodeUploadIDMismatch case errors.Is(err, attachmentdom.ErrMultipartPartsEmpty): return http.StatusBadRequest, apierr.CodeMultipartPartsEmpty + case errors.Is(err, attachmentdom.ErrAvatarTooLarge), + errors.Is(err, attachmentdom.ErrAvatarContentTypeInvalid), + errors.Is(err, attachmentdom.ErrAvatarDecodeFailed): + return http.StatusBadRequest, apierr.CodeAttachmentInvalid + case errors.Is(err, attachmentdom.ErrAvatarOwnerMismatch): + return http.StatusNotFound, apierr.CodeFileNotFound case errors.Is(err, taskdom.ErrActivityNotFound): return http.StatusNotFound, apierr.CodeActivityNotFound case errors.Is(err, taskdom.ErrActivityForbidden): diff --git a/services/api/internal/transport/http/router/router.go b/services/api/internal/transport/http/router/router.go index 668e2759..bd05f9db 100644 --- a/services/api/internal/transport/http/router/router.go +++ b/services/api/internal/transport/http/router/router.go @@ -90,6 +90,9 @@ func New(deps Deps) http.Handler { r.Get("/me", deps.User.GetMe) r.Patch("/me", deps.User.UpdateMe) r.Get("/me/global-permissions", deps.User.GetMyGlobalPermissions) + r.Post("/me/avatar/initiate-upload", deps.User.InitiateAvatarUpload) + r.Post("/me/avatar/complete-upload", deps.User.CompleteAvatarUpload) + r.Delete("/me/avatar", deps.User.DeleteAvatar) // Cross-project "assigned to me" tasks — home page widget. if deps.Task != nil { @@ -171,6 +174,14 @@ func New(deps Deps) http.Handler { r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.GlobalScope(), authz.PermissionAgentsWrite)). Post("/agents/{agentId}/mcp-agent-key", deps.Agent.GenerateGlobalAgentMCPKey) + // Avatar + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.GlobalScope(), authz.PermissionAgentsWrite)). + Post("/agents/{agentId}/avatar/initiate-upload", deps.Agent.InitiateGlobalAvatarUpload) + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.GlobalScope(), authz.PermissionAgentsWrite)). + Post("/agents/{agentId}/avatar/complete-upload", deps.Agent.CompleteGlobalAvatarUpload) + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.GlobalScope(), authz.PermissionAgentsWrite)). + Delete("/agents/{agentId}/avatar", deps.Agent.DeleteGlobalAvatar) + // MCP servers r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.GlobalScope(), authz.PermissionAgentsRead)). Get("/agents/{agentId}/mcp-servers", deps.Agent.ListGlobalAgentMCPServers) @@ -284,6 +295,14 @@ func New(deps Deps) http.Handler { r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionProjectsDelete)). Delete("/", deps.Project.DeleteProject) + // Avatar + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionProjectsWrite)). + Post("/avatar/initiate-upload", deps.Project.InitiateAvatarUpload) + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionProjectsWrite)). + Post("/avatar/complete-upload", deps.Project.CompleteAvatarUpload) + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionProjectsWrite)). + Delete("/avatar", deps.Project.DeleteAvatar) + // Members r.Route("/members", func(r chi.Router) { r.With(httpmw.RequirePublicProjectOrPermissions(deps.ProjectVisibilitySvc, deps.Authorizer, @@ -631,6 +650,14 @@ func New(deps Deps) http.Handler { r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionAgentsWrite)). Post("/{agentId}/mcp-agent-key", deps.Agent.GenerateAgentMCPKey) + // Avatar + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionAgentsWrite)). + Post("/{agentId}/avatar/initiate-upload", deps.Agent.InitiateAvatarUpload) + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionAgentsWrite)). + Post("/{agentId}/avatar/complete-upload", deps.Agent.CompleteAvatarUpload) + r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionAgentsWrite)). + Delete("/{agentId}/avatar", deps.Agent.DeleteAvatar) + // Activity feed r.With(httpmw.RequirePermissions(deps.Authorizer, httpmw.ProjectScopeFromParam("projectId"), authz.PermissionAgentsRead)). Get("/{agentId}/activities", deps.Agent.ListAgentActivities) diff --git a/services/api/internal/transport/http/router/router_test.go b/services/api/internal/transport/http/router/router_test.go index 9dd37b10..10b947ae 100644 --- a/services/api/internal/transport/http/router/router_test.go +++ b/services/api/internal/transport/http/router/router_test.go @@ -14,6 +14,7 @@ import ( "github.com/google/uuid" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" domainauth "github.com/Paca-AI/api/internal/domain/auth" globalroledom "github.com/Paca-AI/api/internal/domain/globalrole" projectdom "github.com/Paca-AI/api/internal/domain/project" @@ -59,6 +60,15 @@ func (m *mockUserSvc) AdminUpdate(context.Context, uuid.UUID, userdom.AdminUpdat func (m *mockUserSvc) ResetPassword(context.Context, uuid.UUID, string) error { return nil } func (m *mockUserSvc) ChangeMyPassword(context.Context, uuid.UUID, string, string) error { return nil } func (m *mockUserSvc) Delete(context.Context, uuid.UUID) error { return nil } +func (m *mockUserSvc) InitiateAvatarUpload(context.Context, uuid.UUID, string, string, int64) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{}, nil +} +func (m *mockUserSvc) CompleteAvatarUpload(context.Context, uuid.UUID, uuid.UUID) (*userdom.User, error) { + return &userdom.User{ID: uuid.New(), Username: "alice", FullName: "Alice", Role: userdom.RoleUser}, nil +} +func (m *mockUserSvc) RemoveAvatar(context.Context, uuid.UUID) (*userdom.User, error) { + return &userdom.User{ID: uuid.New(), Username: "alice", FullName: "Alice", Role: userdom.RoleUser}, nil +} type mockGlobalRoleSvc struct{} @@ -99,6 +109,15 @@ func (s *stubProjectSvc) Update(context.Context, uuid.UUID, projectdom.UpdatePro return nil, nil } func (s *stubProjectSvc) Delete(context.Context, uuid.UUID) error { return nil } +func (s *stubProjectSvc) InitiateAvatarUpload(context.Context, uuid.UUID, string, string, int64, uuid.UUID) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{}, nil +} +func (s *stubProjectSvc) CompleteAvatarUpload(context.Context, uuid.UUID, uuid.UUID) (*projectdom.Project, error) { + return nil, projectdom.ErrNotFound +} +func (s *stubProjectSvc) RemoveAvatar(context.Context, uuid.UUID) (*projectdom.Project, error) { + return nil, projectdom.ErrNotFound +} func (s *stubProjectSvc) ListMembers(context.Context, uuid.UUID) ([]*projectdom.ProjectMember, error) { return nil, nil } diff --git a/services/api/migrations/000033_add_avatar_keys.sql b/services/api/migrations/000033_add_avatar_keys.sql new file mode 100644 index 00000000..5227c706 --- /dev/null +++ b/services/api/migrations/000033_add_avatar_keys.sql @@ -0,0 +1,24 @@ +-- 000033_add_avatar_keys.sql +-- Adds avatar upload support for users and agents. Each owner row stores the +-- object-storage key for two derived, server-generated image variants +-- (avatar_key = 256x256 "full", avatar_thumb_key = 64x64 "thumb") rather +-- than a FK into the `files` table — that table is only used transiently +-- for the pending-upload handshake (see attachmentdom.AvatarService); once +-- an upload completes, the resulting keys are copied here directly so every +-- list endpoint (team page, task lists, activity feeds) can presign a +-- display URL with zero extra joins. +-- +-- agents.avatar_url (000008) is dropped: it was never populated by any code +-- path (no Create/Update DTO ever exposed it), so this is a pure rename to +-- the key-based representation used by the new upload flow. + +BEGIN; + +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_key TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_thumb_key TEXT; + +ALTER TABLE agents DROP COLUMN IF EXISTS avatar_url; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS avatar_key TEXT; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS avatar_thumb_key TEXT; + +COMMIT; diff --git a/services/api/migrations/000034_add_project_avatar_keys.sql b/services/api/migrations/000034_add_project_avatar_keys.sql new file mode 100644 index 00000000..724967ce --- /dev/null +++ b/services/api/migrations/000034_add_project_avatar_keys.sql @@ -0,0 +1,13 @@ +-- 000034_add_project_avatar_keys.sql +-- Adds avatar upload support for projects, same shape as 000033's +-- users/agents columns: avatar_key/avatar_thumb_key hold the object-storage +-- keys of the two server-generated image variants (see +-- attachmentdom.AvatarService), resolved to presigned display URLs at read +-- time rather than stored as URLs. + +BEGIN; + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS avatar_key TEXT; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS avatar_thumb_key TEXT; + +COMMIT; diff --git a/services/api/test/integration/attachment_test.go b/services/api/test/integration/attachment_test.go index b6a49466..784f4c21 100644 --- a/services/api/test/integration/attachment_test.go +++ b/services/api/test/integration/attachment_test.go @@ -5,10 +5,14 @@ import ( "context" "encoding/json" "fmt" + "image" + "image/color" + "image/png" "log/slog" "net/http" "net/http/httptest" "os" + "strings" "sync" "testing" "time" @@ -17,6 +21,7 @@ import ( attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" taskdom "github.com/Paca-AI/api/internal/domain/task" + userdom "github.com/Paca-AI/api/internal/domain/user" "github.com/Paca-AI/api/internal/platform/authz" "github.com/Paca-AI/api/internal/platform/storage" jwttoken "github.com/Paca-AI/api/internal/platform/token" @@ -147,6 +152,7 @@ type fakeStorageClient struct { getURLs map[string]string // key → get URL multipartUploads map[string]*storage.MultipartUpload deletedKeys []string + objects map[string][]byte // key → uploaded bytes (GetObject/PutObject) } func newFakeStorageClient() *fakeStorageClient { @@ -154,6 +160,7 @@ func newFakeStorageClient() *fakeStorageClient { presignedURLs: make(map[string]string), getURLs: make(map[string]string), multipartUploads: make(map[string]*storage.MultipartUpload), + objects: make(map[string][]byte), } } @@ -216,6 +223,23 @@ func (c *fakeStorageClient) DeleteObject(_ context.Context, _, key string) error func (c *fakeStorageClient) EnsureBucket(_ context.Context, _ string) error { return nil } +func (c *fakeStorageClient) GetObject(_ context.Context, _, key string) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + data, ok := c.objects[key] + if !ok { + return nil, fmt.Errorf("fake storage: object %q not found", key) + } + return data, nil +} + +func (c *fakeStorageClient) PutObject(_ context.Context, _, key, _ string, data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + c.objects[key] = data + return nil +} + // --------------------------------------------------------------------------- // Router builder for attachment tests // --------------------------------------------------------------------------- @@ -256,6 +280,32 @@ func buildAttachmentTestRouter(attachRepo *fakeAttachmentRepo, store *fakeStorag }) } +// buildAvatarTestRouter wires a router with the user self-service avatar +// endpoints backed by real (fake) storage — unlike buildAttachmentTestRouter, +// it returns the user repo so tests can seed a user and read back the +// avatar keys persisted by CompleteAvatarUpload. +func buildAvatarTestRouter(attachRepo *fakeAttachmentRepo, store *fakeStorageClient) (http.Handler, *fakeUserRepo) { + tm := jwttoken.New(testSecret, 15*time.Minute, 168*time.Hour) + refreshStore := &fakeRefreshStore{} + userRepo := newFakeUserRepo() + authService := authsvc.New(userRepo, tm, refreshStore, 168*time.Hour, 24*time.Hour) + taskRepo := newFakeTaskRepoIT() + attachmentService := attachmentsvc.New(attachRepo, attachmentsvc.NewTaskOwnerChecker(taskRepo), store, "test-bucket") + userService := usersvc.New(userRepo).WithAvatarService(attachmentService) + log := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + h := router.New(router.Deps{ + TokenManager: tm, + Authorizer: authz.NewAuthorizer(&projectPermStore{}), + Health: handler.NewHealthHandler(), + Auth: handler.NewAuthHandler(authService, testCookieCfg), + User: handler.NewUserHandler(userService, authService).WithAvatarService(attachmentService), + GlobalRole: handler.NewGlobalRoleHandler(&fakeGlobalRoleService{}), + Log: log, + }) + return h, userRepo +} + // fullPermStore returns a projectPermStore granting all task/attachment perms for the given project. func fullPermStore(projectID uuid.UUID) *projectPermStore { return &projectPermStore{ @@ -723,3 +773,151 @@ func TestCrossProjectAccess_Denied(t *testing.T) { t.Errorf("cross-project list: expected 404, got %d: %s", wList.Code, wList.Body.String()) } } + +// --------------------------------------------------------------------------- +// Avatar tests +// --------------------------------------------------------------------------- + +// fakePNG returns a tiny solid-color PNG so CompleteAvatarUpload has real +// image bytes to decode/resize. +func fakePNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode fixture PNG: %v", err) + } + return buf.Bytes() +} + +// keyFromFakeUploadURL recovers the storage key the test harness's +// fakeStorageClient embedded in a presigned PUT URL +// ("https://fake-storage/{bucket}/{key}?sig=put"), so the test can simulate +// the client's direct-to-storage PUT by writing straight into the fake's +// object map before calling complete-upload. +func keyFromFakeUploadURL(t *testing.T, bucket, uploadURL string) string { + t.Helper() + prefix := fmt.Sprintf("https://fake-storage/%s/", bucket) + key, ok := strings.CutPrefix(uploadURL, prefix) + if !ok { + t.Fatalf("upload URL %q does not have expected prefix %q", uploadURL, prefix) + } + key, _, _ = strings.Cut(key, "?") + return key +} + +func TestAvatarUpload_SelfService(t *testing.T) { + userID := uuid.New() + attachRepo := newFakeAttachmentRepo() + store := newFakeStorageClient() + r, userRepo := buildAvatarTestRouter(attachRepo, store) + if err := userRepo.Create(context.Background(), &userdom.User{ + ID: userID, + Username: "avatartester", + FullName: "Avatar Tester", + Role: userdom.RoleUser, + }); err != nil { + t.Fatalf("seed user: %v", err) + } + tok := issueAttachToken(t, userID.String()) + + pngBytes := fakePNG(t, 300, 200) + + // Step 1: initiate. + wInit := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/users/me/avatar/initiate-upload", tok, map[string]any{ + "file_name": "me.png", + "content_type": "image/png", + "file_size": len(pngBytes), + })) + if wInit.Code != http.StatusCreated { + t.Fatalf("initiate: expected 201, got %d: %s", wInit.Code, wInit.Body.String()) + } + initData := decodeAttachData(t, wInit) + fileID, _ := initData["file_id"].(string) + uploadURL, _ := initData["upload_url"].(string) + if fileID == "" || uploadURL == "" { + t.Fatalf("missing file_id/upload_url in initiate response: %v", initData) + } + + // Step 2: simulate the client's direct-to-storage PUT. + store.PutObject(context.Background(), "test-bucket", keyFromFakeUploadURL(t, "test-bucket", uploadURL), "image/png", pngBytes) //nolint:errcheck + + // Step 3: complete. + wComplete := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/users/me/avatar/complete-upload", tok, map[string]any{ + "file_id": fileID, + })) + if wComplete.Code != http.StatusOK { + t.Fatalf("complete: expected 200, got %d: %s", wComplete.Code, wComplete.Body.String()) + } + completeData := decodeAttachData(t, wComplete) + avatarURL, _ := completeData["avatar_url"].(string) + thumbURL, _ := completeData["avatar_thumb_url"].(string) + if avatarURL == "" || thumbURL == "" { + t.Fatalf("expected avatar_url and avatar_thumb_url in complete response, got %v", completeData) + } + + // Step 4: GetMe should now also report the avatar. + wMe := serve(r, authedJSONReq(t.Context(), http.MethodGet, "/api/v1/users/me", tok, nil)) + if wMe.Code != http.StatusOK { + t.Fatalf("get me: expected 200, got %d: %s", wMe.Code, wMe.Body.String()) + } + meData := decodeAttachData(t, wMe) + if meData["avatar_url"] == nil || meData["avatar_url"] == "" { + t.Errorf("expected GetMe to report avatar_url after upload, got %v", meData) + } + + // Step 5: remove the avatar. + wDelete := serve(r, authedJSONReq(t.Context(), http.MethodDelete, "/api/v1/users/me/avatar", tok, nil)) + if wDelete.Code != http.StatusOK { + t.Fatalf("delete avatar: expected 200, got %d: %s", wDelete.Code, wDelete.Body.String()) + } + deleteData := decodeAttachData(t, wDelete) + if deleteData["avatar_url"] != nil { + t.Errorf("expected avatar_url to be cleared after delete, got %v", deleteData["avatar_url"]) + } +} + +func TestAvatarUpload_RejectsNonImageContentType(t *testing.T) { + userID := uuid.New() + r, userRepo := buildAvatarTestRouter(newFakeAttachmentRepo(), newFakeStorageClient()) + if err := userRepo.Create(context.Background(), &userdom.User{ + ID: userID, Username: "avatartester2", FullName: "Avatar Tester 2", Role: userdom.RoleUser, + }); err != nil { + t.Fatalf("seed user: %v", err) + } + tok := issueAttachToken(t, userID.String()) + + w := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/users/me/avatar/initiate-upload", tok, map[string]any{ + "file_name": "malware.exe", + "content_type": "application/octet-stream", + "file_size": 1024, + })) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for non-image content type, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAvatarUpload_RejectsOversizedFile(t *testing.T) { + userID := uuid.New() + r, userRepo := buildAvatarTestRouter(newFakeAttachmentRepo(), newFakeStorageClient()) + if err := userRepo.Create(context.Background(), &userdom.User{ + ID: userID, Username: "avatartester3", FullName: "Avatar Tester 3", Role: userdom.RoleUser, + }); err != nil { + t.Fatalf("seed user: %v", err) + } + tok := issueAttachToken(t, userID.String()) + + w := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/users/me/avatar/initiate-upload", tok, map[string]any{ + "file_name": "huge.png", + "content_type": "image/png", + "file_size": attachmentdom.MaxAvatarUploadSize + 1, + })) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for oversized file, got %d: %s", w.Code, w.Body.String()) + } +} From fd029f51566710e76de4fceea400c433e7ad8417 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sat, 8 Aug 2026 15:55:00 +0000 Subject: [PATCH 2/3] feat: implement avatar upload functionality with size and dimension validation --- services/api/internal/bootstrap/app.go | 7 + .../domain/attachment/avatar_service.go | 14 ++ .../api/internal/domain/attachment/errors.go | 4 + .../service/agent/agent_service_test.go | 170 +++++++++++++++ .../service/attachment/avatar_service.go | 45 ++++ .../service/attachment/avatar_service_test.go | 61 ++++++ .../service/project/project_service_test.go | 154 ++++++++++++++ .../service/user/user_service_test.go | 113 ++++++++++ .../transport/http/presenter/response.go | 3 +- .../api/test/integration/attachment_test.go | 197 ++++++++++++++++++ 10 files changed, 767 insertions(+), 1 deletion(-) diff --git a/services/api/internal/bootstrap/app.go b/services/api/internal/bootstrap/app.go index d4f01ff5..82f345dc 100644 --- a/services/api/internal/bootstrap/app.go +++ b/services/api/internal/bootstrap/app.go @@ -208,6 +208,13 @@ func New(cfg *config.Config) (*App, error) { attachmentService := attachmentsvc.New(attachmentRepo, attachmentsvc.NewTaskOwnerChecker(taskRepo), storageClient, cfg.Storage.Bucket) userService = userService.WithAvatarService(attachmentService) agentService = agentService.WithAvatarService(attachmentService) + // Unlike userService/agentService above, this return value isn't + // reassigned: projectService (the cached wrapper built from + // projectServiceBase back at its construction) already holds this same + // *Service pointer, and WithAvatarService mutates it in place, so the + // config takes effect through projectService too. Reassigning here would + // itself go unused (and trip staticcheck's SA4006) since projectServiceBase + // is never read again after this line. projectServiceBase.WithAvatarService(attachmentService) // --- API Key management ------------------------------------------------- diff --git a/services/api/internal/domain/attachment/avatar_service.go b/services/api/internal/domain/attachment/avatar_service.go index f2665417..37ef73d4 100644 --- a/services/api/internal/domain/attachment/avatar_service.go +++ b/services/api/internal/domain/attachment/avatar_service.go @@ -51,8 +51,22 @@ type AvatarService interface { // MaxAvatarUploadSize caps the raw (pre-resize) avatar upload. Comfortably // under storage.MultipartThreshold so avatars never need the multipart path. +// Enforced twice: against the client-declared size at initiate time, and +// again against the actual downloaded object at complete time — a client +// can PUT more bytes than it declared straight to the presigned URL, so the +// declared-size check alone is not sufficient. const MaxAvatarUploadSize = 5 * 1024 * 1024 // 5 MiB +// MaxAvatarDecodeDimension caps the width/height (in pixels) an uploaded +// avatar may declare before the server will fully decode it. Checked via a +// cheap header-only DecodeConfig read, before the full pixel buffer is +// allocated — a small, highly-compressed file can otherwise declare +// dimensions large enough to exhaust server memory on decode (a +// "decompression bomb"). Comfortably above any realistic photo (a 4K photo +// is 3840x2160) while keeping the worst-case decode buffer bounded +// (8192x8192 RGBA is ~256 MiB). +const MaxAvatarDecodeDimension = 8192 + // AvatarContentTypes is the whitelist of accepted raw upload content types. var AvatarContentTypes = map[string]bool{ "image/png": true, diff --git a/services/api/internal/domain/attachment/errors.go b/services/api/internal/domain/attachment/errors.go index 6833d68a..0591f95f 100644 --- a/services/api/internal/domain/attachment/errors.go +++ b/services/api/internal/domain/attachment/errors.go @@ -38,6 +38,10 @@ var ( // ErrAvatarDecodeFailed is returned when the uploaded bytes cannot be // decoded as an image of one of the accepted content types. ErrAvatarDecodeFailed = errors.New("uploaded file is not a valid image") + // ErrAvatarDimensionsTooLarge is returned when an uploaded image's + // declared pixel dimensions exceed MaxAvatarDecodeDimension, checked + // before the full image is decoded into memory. + ErrAvatarDimensionsTooLarge = errors.New("image dimensions exceed the maximum allowed for an avatar") // ErrAvatarOwnerMismatch is returned when a file being completed does not // belong to the claimed avatar owner (storage key prefix mismatch). ErrAvatarOwnerMismatch = errors.New("file does not belong to the specified avatar owner") diff --git a/services/api/internal/service/agent/agent_service_test.go b/services/api/internal/service/agent/agent_service_test.go index dd8c779b..2049aab9 100644 --- a/services/api/internal/service/agent/agent_service_test.go +++ b/services/api/internal/service/agent/agent_service_test.go @@ -2,15 +2,57 @@ package agentsvc import ( "context" + "sync" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" agentdom "github.com/Paca-AI/api/internal/domain/agent" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" plugindom "github.com/Paca-AI/api/internal/domain/plugin" ) +// --------------------------------------------------------------------------- +// Minimal fake avatar service +// --------------------------------------------------------------------------- + +// fakeAvatarService is a bare-bones attachmentdom.AvatarService double — +// CompleteAvatarUpload always returns nextKeys, and DeleteAvatarObjects +// records what it was asked to delete so tests can assert the *previous* +// avatar's keys were cleaned up after a replace. +type fakeAvatarService struct { + mu sync.Mutex + nextKeys *attachmentdom.AvatarKeys + deletedKeys []string + initiateCalled bool +} + +func (f *fakeAvatarService) InitiateAvatarUpload(context.Context, attachmentdom.AvatarUploadInput) (*attachmentdom.UploadSession, error) { + f.mu.Lock() + f.initiateCalled = true + f.mu.Unlock() + return &attachmentdom.UploadSession{FileID: uuid.New(), UploadURL: "https://fake/upload"}, nil +} + +func (f *fakeAvatarService) CompleteAvatarUpload(context.Context, attachmentdom.AvatarCompleteInput) (*attachmentdom.AvatarKeys, error) { + return f.nextKeys, nil +} + +func (f *fakeAvatarService) ResolveAvatarURL(context.Context, *string) (*string, error) { + return nil, nil +} + +func (f *fakeAvatarService) DeleteAvatarObjects(_ context.Context, keys ...*string) { + f.mu.Lock() + defer f.mu.Unlock() + for _, k := range keys { + if k != nil && *k != "" { + f.deletedKeys = append(f.deletedKeys, *k) + } + } +} + // findAgentByIDReturning stubs mockAgentRepo.findAgentByID to return a // minimal agent of the given type, regardless of the requested id — used by // tests exercising MCP server / skill / env var writes, which now check the @@ -2962,3 +3004,131 @@ func TestDeleteEnvVar_ACPAgent_ReturnsError(t *testing.T) { assert.ErrorIs(t, err, agentdom.ErrNotSupportedForACPAgent) } + +// --------------------------------------------------------------------------- +// Avatar +// --------------------------------------------------------------------------- + +func TestInitiateAvatarUpload_NoAvatarService_ReturnsError(t *testing.T) { + repo := &mockAgentRepo{findAgentByID: findAgentByIDReturning(agentdom.AgentTypeLLM)} + svc := New(repo, &mockProjectRepo{}, nil, &mockPluginRepo{}) // WithAvatarService never called + + _, err := svc.InitiateAvatarUpload(context.Background(), uuid.New(), uuid.New(), "me.png", "image/png", 1024, uuid.New()) + + assert.ErrorIs(t, err, ErrAvatarServiceRequired) +} + +func TestInitiateAvatarUpload_AgentNotInProject_NeverCallsAvatarService(t *testing.T) { + repo := &mockAgentRepo{ + findAgentByID: func(context.Context, uuid.UUID) (*agentdom.Agent, error) { + return nil, agentdom.ErrAgentNotFound + }, + } + avatarSvc := &fakeAvatarService{} + svc := New(repo, &mockProjectRepo{}, nil, &mockPluginRepo{}).WithAvatarService(avatarSvc) + + _, err := svc.InitiateAvatarUpload(context.Background(), uuid.New(), uuid.New(), "me.png", "image/png", 1024, uuid.New()) + + assert.ErrorIs(t, err, agentdom.ErrAgentNotFound) + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + assert.False(t, avatarSvc.initiateCalled, "avatar service must not be reached once ownership fails") +} + +func TestCompleteAvatarUpload_SwapsKeysAndDeletesOld(t *testing.T) { + oldKey, oldThumbKey := "avatars/agents/a1/old/full.png", "avatars/agents/a1/old/thumb.png" + agentID := uuid.New() + projectID := uuid.New() + var updated *agentdom.Agent + repo := &mockAgentRepo{ + findAgentByID: func(_ context.Context, id uuid.UUID) (*agentdom.Agent, error) { + return &agentdom.Agent{ID: id, ProjectID: projectID, AvatarKey: &oldKey, AvatarThumbKey: &oldThumbKey}, nil + }, + updateAgent: func(_ context.Context, a *agentdom.Agent) error { + updated = a + return nil + }, + } + avatarSvc := &fakeAvatarService{ + nextKeys: &attachmentdom.AvatarKeys{Key: "avatars/agents/a1/new/full.png", ThumbKey: "avatars/agents/a1/new/thumb.png"}, + } + svc := New(repo, &mockProjectRepo{}, nil, &mockPluginRepo{}).WithAvatarService(avatarSvc) + + a, err := svc.CompleteAvatarUpload(context.Background(), projectID, agentID, uuid.New()) + + assert.NoError(t, err) + assert.Equal(t, avatarSvc.nextKeys.Key, *a.AvatarKey) + assert.Equal(t, avatarSvc.nextKeys.ThumbKey, *a.AvatarThumbKey) + if assert.NotNil(t, updated) { + assert.Equal(t, avatarSvc.nextKeys.Key, *updated.AvatarKey) + } + + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + assert.ElementsMatch(t, []string{oldKey, oldThumbKey}, avatarSvc.deletedKeys) +} + +func TestRemoveAvatar_NoExistingAvatar_NoOps(t *testing.T) { + agentID := uuid.New() + projectID := uuid.New() + updateCalled := false + repo := &mockAgentRepo{ + findAgentByID: func(_ context.Context, id uuid.UUID) (*agentdom.Agent, error) { + return &agentdom.Agent{ID: id, ProjectID: projectID}, nil + }, + updateAgent: func(context.Context, *agentdom.Agent) error { + updateCalled = true + return nil + }, + } + avatarSvc := &fakeAvatarService{} + svc := New(repo, &mockProjectRepo{}, nil, &mockPluginRepo{}).WithAvatarService(avatarSvc) + + _, err := svc.RemoveAvatar(context.Background(), projectID, agentID) + + assert.NoError(t, err) + assert.False(t, updateCalled, "expected repo.UpdateAgent not to be called when the agent has no avatar") + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + assert.Empty(t, avatarSvc.deletedKeys) +} + +func TestCompleteGlobalAvatarUpload_RejectsProjectScopedAgent(t *testing.T) { + agentID := uuid.New() + repo := &mockAgentRepo{ + findAgentByID: func(_ context.Context, id uuid.UUID) (*agentdom.Agent, error) { + return &agentdom.Agent{ID: id, AgentScope: agentdom.AgentScopeProject, ProjectID: uuid.New()}, nil + }, + } + avatarSvc := &fakeAvatarService{} + svc := New(repo, &mockProjectRepo{}, nil, &mockPluginRepo{}).WithAvatarService(avatarSvc) + + _, err := svc.CompleteGlobalAvatarUpload(context.Background(), agentID, uuid.New()) + + assert.ErrorIs(t, err, agentdom.ErrAgentNotFound) + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + assert.Empty(t, avatarSvc.deletedKeys, "avatar service must not be touched for a scope-mismatched agent") +} + +func TestRemoveGlobalAvatar_ClearsKeysAndDeletesObjects(t *testing.T) { + key, thumbKey := "avatars/agents/a2/full.png", "avatars/agents/a2/thumb.png" + agentID := uuid.New() + repo := &mockAgentRepo{ + findAgentByID: func(_ context.Context, id uuid.UUID) (*agentdom.Agent, error) { + return &agentdom.Agent{ID: id, AgentScope: agentdom.AgentScopeGlobal, AvatarKey: &key, AvatarThumbKey: &thumbKey}, nil + }, + updateAgent: func(context.Context, *agentdom.Agent) error { return nil }, + } + avatarSvc := &fakeAvatarService{} + svc := New(repo, &mockProjectRepo{}, nil, &mockPluginRepo{}).WithAvatarService(avatarSvc) + + a, err := svc.RemoveGlobalAvatar(context.Background(), agentID) + + assert.NoError(t, err) + assert.Nil(t, a.AvatarKey) + assert.Nil(t, a.AvatarThumbKey) + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + assert.ElementsMatch(t, []string{key, thumbKey}, avatarSvc.deletedKeys) +} diff --git a/services/api/internal/service/attachment/avatar_service.go b/services/api/internal/service/attachment/avatar_service.go index d6cf7e64..b8d2fb81 100644 --- a/services/api/internal/service/attachment/avatar_service.go +++ b/services/api/internal/service/attachment/avatar_service.go @@ -2,6 +2,7 @@ package attachmentsvc import ( "bytes" + "errors" "fmt" "image" _ "image/gif" // register GIF decoder with image.Decode @@ -101,9 +102,19 @@ func (s *Service) CompleteAvatarUpload(ctx context.Context, in attachmentdom.Ava if err != nil { return nil, fmt.Errorf("attachment svc: download avatar upload: %w", err) } + // The client declares file_size at initiate time, but the presigned PUT + // URL enforces nothing about the actual object it accepts — a client can + // upload more bytes than it declared. Re-check the real object here, + // before doing any decode work on it. + if len(raw) > attachmentdom.MaxAvatarUploadSize { + return nil, attachmentdom.ErrAvatarTooLarge + } img, err := decodeAvatarImage(raw, f.ContentType) if err != nil { + if errors.Is(err, attachmentdom.ErrAvatarDimensionsTooLarge) { + return nil, err + } return nil, attachmentdom.ErrAvatarDecodeFailed } square := cropToSquare(img) @@ -178,7 +189,15 @@ func avatarOwnerPrefix(kind attachmentdom.AvatarOwnerKind, ownerID uuid.UUID) st // (golang.org/x/image/webp, decode-only) — png/jpeg/gif decoders are // registered with the stdlib image.Decode dispatcher via blank imports // above. +// +// Dimensions are checked first via a cheap, header-only DecodeConfig read +// before the full decoder (which allocates a pixel buffer proportional to +// width*height) ever runs — otherwise a small, highly-compressed file could +// declare dimensions large enough to exhaust server memory on decode. func decodeAvatarImage(data []byte, contentType string) (image.Image, error) { + if err := checkAvatarDimensions(data, contentType); err != nil { + return nil, err + } if contentType == "image/webp" { return webp.Decode(bytes.NewReader(data)) } @@ -186,6 +205,32 @@ func decodeAvatarImage(data []byte, contentType string) (image.Image, error) { return img, err } +// checkAvatarDimensions rejects images whose declared width or height +// exceeds MaxAvatarDecodeDimension, without decoding the full pixel buffer. +func checkAvatarDimensions(data []byte, contentType string) error { + var width, height int + if contentType == "image/webp" { + cfg, err := webp.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return err + } + width, height = cfg.Width, cfg.Height + } else { + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return err + } + width, height = cfg.Width, cfg.Height + } + if width <= 0 || height <= 0 { + return attachmentdom.ErrAvatarDecodeFailed + } + if width > attachmentdom.MaxAvatarDecodeDimension || height > attachmentdom.MaxAvatarDecodeDimension { + return attachmentdom.ErrAvatarDimensionsTooLarge + } + return nil +} + // cropToSquare returns the center square crop of img as a fresh RGBA image. func cropToSquare(img image.Image) *image.RGBA { b := img.Bounds() diff --git a/services/api/internal/service/attachment/avatar_service_test.go b/services/api/internal/service/attachment/avatar_service_test.go index 03b24df7..93f27a5e 100644 --- a/services/api/internal/service/attachment/avatar_service_test.go +++ b/services/api/internal/service/attachment/avatar_service_test.go @@ -2,10 +2,15 @@ package attachmentsvc import ( "bytes" + "encoding/binary" + "errors" + "hash/crc32" "image" "image/color" "image/png" "testing" + + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" ) func solidImage(w, h int, c color.Color) image.Image { @@ -77,3 +82,59 @@ func TestDecodeAvatarImage_InvalidBytes(t *testing.T) { t.Fatal("expected decodeAvatarImage to reject non-image bytes, got nil error") } } + +// fakePNGHeader builds a syntactically valid PNG signature + IHDR chunk +// declaring width x height, with no pixel data. image.DecodeConfig only +// needs the IHDR chunk to report dimensions, so this lets the oversized- +// dimensions test below exercise the guard against an implausibly large +// declared image without actually allocating that much memory. +func fakePNGHeader(width, height uint32) []byte { + var buf bytes.Buffer + buf.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) + + ihdr := make([]byte, 13) + binary.BigEndian.PutUint32(ihdr[0:4], width) + binary.BigEndian.PutUint32(ihdr[4:8], height) + ihdr[8] = 8 // bit depth + ihdr[9] = 6 // color type: truecolor + alpha + ihdr[10] = 0 // compression method + ihdr[11] = 0 // filter method + ihdr[12] = 0 // interlace method + writePNGChunk(&buf, "IHDR", ihdr) + return buf.Bytes() +} + +func writePNGChunk(buf *bytes.Buffer, chunkType string, data []byte) { + var lenBuf [4]byte + binary.BigEndian.PutUint32(lenBuf[:], uint32(len(data))) + buf.Write(lenBuf[:]) + + typeAndData := append([]byte(chunkType), data...) + buf.Write(typeAndData) + + var crcBuf [4]byte + binary.BigEndian.PutUint32(crcBuf[:], crc32.ChecksumIEEE(typeAndData)) + buf.Write(crcBuf[:]) +} + +func TestDecodeAvatarImage_RejectsOversizedDimensions(t *testing.T) { + // Declares a 50000x50000 image (2.5 billion pixels, ~10 GiB as RGBA) — + // well past MaxAvatarDecodeDimension. Must be rejected before the full + // decoder ever runs. + huge := fakePNGHeader(50000, 50000) + _, err := decodeAvatarImage(huge, "image/png") + if !errors.Is(err, attachmentdom.ErrAvatarDimensionsTooLarge) { + t.Fatalf("decodeAvatarImage(50000x50000): got %v, want ErrAvatarDimensionsTooLarge", err) + } +} + +func TestDecodeAvatarImage_AllowsDimensionsWithinCap(t *testing.T) { + square := solidImage(200, 200, color.White) + var buf bytes.Buffer + if err := png.Encode(&buf, square); err != nil { + t.Fatalf("encode fixture PNG: %v", err) + } + if _, err := decodeAvatarImage(buf.Bytes(), "image/png"); err != nil { + t.Fatalf("decodeAvatarImage(200x200): unexpected error: %v", err) + } +} diff --git a/services/api/internal/service/project/project_service_test.go b/services/api/internal/service/project/project_service_test.go index 459a90af..3cbedfb6 100644 --- a/services/api/internal/service/project/project_service_test.go +++ b/services/api/internal/service/project/project_service_test.go @@ -9,10 +9,51 @@ import ( "github.com/google/uuid" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" projectdom "github.com/Paca-AI/api/internal/domain/project" taskdom "github.com/Paca-AI/api/internal/domain/task" ) +// --------------------------------------------------------------------------- +// Minimal fake avatar service +// --------------------------------------------------------------------------- + +// fakeAvatarService is a bare-bones attachmentdom.AvatarService double — +// CompleteAvatarUpload always returns nextKeys, and DeleteAvatarObjects +// records what it was asked to delete so tests can assert the *previous* +// avatar's keys were cleaned up after a replace. +type fakeAvatarService struct { + mu sync.Mutex + nextKeys *attachmentdom.AvatarKeys + completeErr error + deletedKeys []string +} + +func (f *fakeAvatarService) InitiateAvatarUpload(context.Context, attachmentdom.AvatarUploadInput) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{FileID: uuid.New(), UploadURL: "https://fake/upload"}, nil +} + +func (f *fakeAvatarService) CompleteAvatarUpload(context.Context, attachmentdom.AvatarCompleteInput) (*attachmentdom.AvatarKeys, error) { + if f.completeErr != nil { + return nil, f.completeErr + } + return f.nextKeys, nil +} + +func (f *fakeAvatarService) ResolveAvatarURL(context.Context, *string) (*string, error) { + return nil, nil +} + +func (f *fakeAvatarService) DeleteAvatarObjects(_ context.Context, keys ...*string) { + f.mu.Lock() + defer f.mu.Unlock() + for _, k := range keys { + if k != nil && *k != "" { + f.deletedKeys = append(f.deletedKeys, *k) + } + } +} + // --------------------------------------------------------------------------- // Minimal fake project repository // --------------------------------------------------------------------------- @@ -468,3 +509,116 @@ func TestSuggestPrefix_Cases(t *testing.T) { } } } + +// --------------------------------------------------------------------------- +// Avatar +// --------------------------------------------------------------------------- + +func TestInitiateAvatarUpload_NoAvatarService_ReturnsError(t *testing.T) { + svc := New(newFakeProjectRepo(), nil, nil) // WithAvatarService never called + _, err := svc.InitiateAvatarUpload(context.Background(), uuid.New(), "me.png", "image/png", 1024, uuid.New()) + if !errors.Is(err, ErrAvatarServiceRequired) { + t.Fatalf("expected ErrAvatarServiceRequired, got %v", err) + } +} + +func TestCompleteAvatarUpload_SwapsKeysAndDeletesOld(t *testing.T) { + ctx := context.Background() + repo := newFakeProjectRepo() + oldKey, oldThumbKey := "avatars/projects/p1/old/full.png", "avatars/projects/p1/old/thumb.png" + projectID := uuid.New() + if err := repo.Create(ctx, &projectdom.Project{ + ID: projectID, + Name: "Has Avatar", + AvatarKey: &oldKey, + AvatarThumbKey: &oldThumbKey, + CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("seed project: %v", err) + } + + avatarSvc := &fakeAvatarService{ + nextKeys: &attachmentdom.AvatarKeys{Key: "avatars/projects/p1/new/full.png", ThumbKey: "avatars/projects/p1/new/thumb.png"}, + } + svc := New(repo, nil, nil).WithAvatarService(avatarSvc) + + p, err := svc.CompleteAvatarUpload(ctx, projectID, uuid.New()) + if err != nil { + t.Fatalf("CompleteAvatarUpload: %v", err) + } + if p.AvatarKey == nil || *p.AvatarKey != avatarSvc.nextKeys.Key { + t.Errorf("expected AvatarKey %q, got %v", avatarSvc.nextKeys.Key, p.AvatarKey) + } + if p.AvatarThumbKey == nil || *p.AvatarThumbKey != avatarSvc.nextKeys.ThumbKey { + t.Errorf("expected AvatarThumbKey %q, got %v", avatarSvc.nextKeys.ThumbKey, p.AvatarThumbKey) + } + + stored, err := repo.FindByID(ctx, projectID) + if err != nil { + t.Fatalf("FindByID after complete: %v", err) + } + if stored.AvatarKey == nil || *stored.AvatarKey != avatarSvc.nextKeys.Key { + t.Errorf("persisted AvatarKey not updated, got %v", stored.AvatarKey) + } + + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + if len(avatarSvc.deletedKeys) != 2 { + t.Fatalf("expected the two old keys to be deleted, got %v", avatarSvc.deletedKeys) + } + deleted := map[string]bool{avatarSvc.deletedKeys[0]: true, avatarSvc.deletedKeys[1]: true} + if !deleted[oldKey] || !deleted[oldThumbKey] { + t.Errorf("expected old keys %q/%q to be deleted, got %v", oldKey, oldThumbKey, avatarSvc.deletedKeys) + } +} + +func TestRemoveAvatar_NoExistingAvatar_NoOps(t *testing.T) { + ctx := context.Background() + repo := newFakeProjectRepo() + projectID := uuid.New() + if err := repo.Create(ctx, &projectdom.Project{ID: projectID, Name: "No Avatar", CreatedAt: time.Now()}); err != nil { + t.Fatalf("seed project: %v", err) + } + + avatarSvc := &fakeAvatarService{} + svc := New(repo, nil, nil).WithAvatarService(avatarSvc) + + if _, err := svc.RemoveAvatar(ctx, projectID); err != nil { + t.Fatalf("RemoveAvatar: %v", err) + } + + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + if len(avatarSvc.deletedKeys) != 0 { + t.Errorf("expected no delete calls when project has no avatar, got %v", avatarSvc.deletedKeys) + } +} + +func TestRemoveAvatar_ClearsKeysAndDeletesObjects(t *testing.T) { + ctx := context.Background() + repo := newFakeProjectRepo() + key, thumbKey := "avatars/projects/p2/full.png", "avatars/projects/p2/thumb.png" + projectID := uuid.New() + if err := repo.Create(ctx, &projectdom.Project{ + ID: projectID, Name: "Has Avatar", AvatarKey: &key, AvatarThumbKey: &thumbKey, CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("seed project: %v", err) + } + + avatarSvc := &fakeAvatarService{} + svc := New(repo, nil, nil).WithAvatarService(avatarSvc) + + p, err := svc.RemoveAvatar(ctx, projectID) + if err != nil { + t.Fatalf("RemoveAvatar: %v", err) + } + if p.AvatarKey != nil || p.AvatarThumbKey != nil { + t.Errorf("expected avatar keys cleared, got %v / %v", p.AvatarKey, p.AvatarThumbKey) + } + + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + if len(avatarSvc.deletedKeys) != 2 { + t.Errorf("expected both keys deleted, got %v", avatarSvc.deletedKeys) + } +} diff --git a/services/api/internal/service/user/user_service_test.go b/services/api/internal/service/user/user_service_test.go index b68140ca..31c8a37e 100644 --- a/services/api/internal/service/user/user_service_test.go +++ b/services/api/internal/service/user/user_service_test.go @@ -4,16 +4,54 @@ import ( "context" "errors" "reflect" + "sync" "testing" "github.com/google/uuid" + attachmentdom "github.com/Paca-AI/api/internal/domain/attachment" globalroledom "github.com/Paca-AI/api/internal/domain/globalrole" userdom "github.com/Paca-AI/api/internal/domain/user" "github.com/Paca-AI/api/internal/platform/authz" usersvc "github.com/Paca-AI/api/internal/service/user" ) +// --------------------------------------------------------------------------- +// stub avatar service +// --------------------------------------------------------------------------- + +// stubAvatarService is a bare-bones attachmentdom.AvatarService double — +// CompleteAvatarUpload always returns nextKeys, and DeleteAvatarObjects +// records what it was asked to delete so tests can assert the *previous* +// avatar's keys were cleaned up after a replace. +type stubAvatarService struct { + mu sync.Mutex + nextKeys *attachmentdom.AvatarKeys + deletedKeys []string +} + +func (s *stubAvatarService) InitiateAvatarUpload(context.Context, attachmentdom.AvatarUploadInput) (*attachmentdom.UploadSession, error) { + return &attachmentdom.UploadSession{FileID: uuid.New(), UploadURL: "https://fake/upload"}, nil +} + +func (s *stubAvatarService) CompleteAvatarUpload(context.Context, attachmentdom.AvatarCompleteInput) (*attachmentdom.AvatarKeys, error) { + return s.nextKeys, nil +} + +func (s *stubAvatarService) ResolveAvatarURL(context.Context, *string) (*string, error) { + return nil, nil +} + +func (s *stubAvatarService) DeleteAvatarObjects(_ context.Context, keys ...*string) { + s.mu.Lock() + defer s.mu.Unlock() + for _, k := range keys { + if k != nil && *k != "" { + s.deletedKeys = append(s.deletedKeys, *k) + } + } +} + // --------------------------------------------------------------------------- // stub repository // --------------------------------------------------------------------------- @@ -502,3 +540,78 @@ func TestDelete_RepoError(t *testing.T) { t.Fatalf("expected repo error, got %v", err) } } + +// --------------------------------------------------------------------------- +// Avatar +// --------------------------------------------------------------------------- + +func TestInitiateAvatarUpload_NoAvatarService_ReturnsError(t *testing.T) { + svc := usersvc.New(&stubRepo{}) // WithAvatarService never called + _, err := svc.InitiateAvatarUpload(context.Background(), uuid.New(), "me.png", "image/png", 1024) + if !errors.Is(err, usersvc.ErrAvatarServiceRequired) { + t.Fatalf("expected ErrAvatarServiceRequired, got %v", err) + } +} + +func TestCompleteAvatarUpload_SwapsKeysAndDeletesOld(t *testing.T) { + oldKey, oldThumbKey := "avatars/users/u1/old/full.png", "avatars/users/u1/old/thumb.png" + userID := uuid.New() + var updated *userdom.User + repo := &stubRepo{ + findByID: func(_ context.Context, id uuid.UUID) (*userdom.User, error) { + return &userdom.User{ID: id, AvatarKey: &oldKey, AvatarThumbKey: &oldThumbKey}, nil + }, + update: func(_ context.Context, u *userdom.User) error { + updated = u + return nil + }, + } + avatarSvc := &stubAvatarService{ + nextKeys: &attachmentdom.AvatarKeys{Key: "avatars/users/u1/new/full.png", ThumbKey: "avatars/users/u1/new/thumb.png"}, + } + svc := usersvc.New(repo).WithAvatarService(avatarSvc) + + u, err := svc.CompleteAvatarUpload(context.Background(), userID, uuid.New()) + if err != nil { + t.Fatalf("CompleteAvatarUpload: %v", err) + } + if u.AvatarKey == nil || *u.AvatarKey != avatarSvc.nextKeys.Key { + t.Errorf("expected AvatarKey %q, got %v", avatarSvc.nextKeys.Key, u.AvatarKey) + } + if updated == nil || updated.AvatarKey == nil || *updated.AvatarKey != avatarSvc.nextKeys.Key { + t.Errorf("expected repo.Update to persist the new AvatarKey, got %v", updated) + } + + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + if len(avatarSvc.deletedKeys) != 2 { + t.Fatalf("expected the two old keys to be deleted, got %v", avatarSvc.deletedKeys) + } +} + +func TestRemoveAvatar_NoExistingAvatar_NoOps(t *testing.T) { + updateCalled := false + repo := &stubRepo{ + findByID: func(_ context.Context, id uuid.UUID) (*userdom.User, error) { + return &userdom.User{ID: id}, nil + }, + update: func(context.Context, *userdom.User) error { + updateCalled = true + return nil + }, + } + avatarSvc := &stubAvatarService{} + svc := usersvc.New(repo).WithAvatarService(avatarSvc) + + if _, err := svc.RemoveAvatar(context.Background(), uuid.New()); err != nil { + t.Fatalf("RemoveAvatar: %v", err) + } + if updateCalled { + t.Error("expected repo.Update not to be called when the user has no avatar") + } + avatarSvc.mu.Lock() + defer avatarSvc.mu.Unlock() + if len(avatarSvc.deletedKeys) != 0 { + t.Errorf("expected no delete calls when user has no avatar, got %v", avatarSvc.deletedKeys) + } +} diff --git a/services/api/internal/transport/http/presenter/response.go b/services/api/internal/transport/http/presenter/response.go index ece0cca1..90ae521a 100644 --- a/services/api/internal/transport/http/presenter/response.go +++ b/services/api/internal/transport/http/presenter/response.go @@ -244,7 +244,8 @@ func statusAndCodeFor(err error) (int, apierr.Code) { return http.StatusBadRequest, apierr.CodeMultipartPartsEmpty case errors.Is(err, attachmentdom.ErrAvatarTooLarge), errors.Is(err, attachmentdom.ErrAvatarContentTypeInvalid), - errors.Is(err, attachmentdom.ErrAvatarDecodeFailed): + errors.Is(err, attachmentdom.ErrAvatarDecodeFailed), + errors.Is(err, attachmentdom.ErrAvatarDimensionsTooLarge): return http.StatusBadRequest, apierr.CodeAttachmentInvalid case errors.Is(err, attachmentdom.ErrAvatarOwnerMismatch): return http.StatusNotFound, apierr.CodeFileNotFound diff --git a/services/api/test/integration/attachment_test.go b/services/api/test/integration/attachment_test.go index 784f4c21..9ed895cc 100644 --- a/services/api/test/integration/attachment_test.go +++ b/services/api/test/integration/attachment_test.go @@ -306,6 +306,35 @@ func buildAvatarTestRouter(attachRepo *fakeAttachmentRepo, store *fakeStorageCli return h, userRepo } +// buildProjectAvatarTestRouter wires a router with the project avatar +// endpoints (WithProjectAvatarService) backed by real (fake) storage, plus +// enough of the project CRUD surface to create a project to run avatar +// requests against. +func buildProjectAvatarTestRouter(attachRepo *fakeAttachmentRepo, store *fakeStorageClient, permStore *projectPermStore) http.Handler { + tm := jwttoken.New(testSecret, 15*time.Minute, 168*time.Hour) + refreshStore := &fakeRefreshStore{} + userRepo := newFakeUserRepo() + authService := authsvc.New(userRepo, tm, refreshStore, 168*time.Hour, 24*time.Hour) + projectRepo := newFakeProjectRepo() + taskRepo := newFakeTaskRepoIT() + projectService := projectsvc.New(projectRepo, taskRepo, nil) + attachmentService := attachmentsvc.New(attachRepo, attachmentsvc.NewTaskOwnerChecker(taskRepo), store, "test-bucket") + projectService.WithAvatarService(attachmentService) + log := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + return router.New(router.Deps{ + TokenManager: tm, + Authorizer: authz.NewAuthorizer(permStore), + ProjectVisibilitySvc: projectService, + Health: handler.NewHealthHandler(), + Auth: handler.NewAuthHandler(authService, testCookieCfg), + GlobalRole: handler.NewGlobalRoleHandler(&fakeGlobalRoleService{}), + Project: handler.NewProjectHandler(projectService, authz.NewAuthorizer(permStore), + handler.WithProjectAvatarService(attachmentService)), + Log: log, + }) +} + // fullPermStore returns a projectPermStore granting all task/attachment perms for the given project. func fullPermStore(projectID uuid.UUID) *projectPermStore { return &projectPermStore{ @@ -921,3 +950,171 @@ func TestAvatarUpload_RejectsOversizedFile(t *testing.T) { t.Errorf("expected 400 for oversized file, got %d: %s", w.Code, w.Body.String()) } } + +// TestAvatarUpload_OwnerMismatch_RejectsCompletingAnotherUsersUpload proves a +// file initiated for one owner can't be completed against a different +// owner's avatar, even if the second owner learns the file_id — the storage +// key embeds the owning user's ID, and CompleteAvatarUpload checks it. +func TestAvatarUpload_OwnerMismatch_RejectsCompletingAnotherUsersUpload(t *testing.T) { + userA, userB := uuid.New(), uuid.New() + repo := newFakeAttachmentRepo() + store := newFakeStorageClient() + r, userRepo := buildAvatarTestRouter(repo, store) + for _, u := range []*userdom.User{ + {ID: userA, Username: "owner-a", FullName: "Owner A", Role: userdom.RoleUser}, + {ID: userB, Username: "owner-b", FullName: "Owner B", Role: userdom.RoleUser}, + } { + if err := userRepo.Create(context.Background(), u); err != nil { + t.Fatalf("seed user %s: %v", u.Username, err) + } + } + tokA := issueAttachToken(t, userA.String()) + tokB := issueAttachToken(t, userB.String()) + + pngBytes := fakePNG(t, 50, 50) + + // User A initiates and "uploads" to storage. + wInit := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/users/me/avatar/initiate-upload", tokA, map[string]any{ + "file_name": "a.png", + "content_type": "image/png", + "file_size": len(pngBytes), + })) + if wInit.Code != http.StatusCreated { + t.Fatalf("initiate as user A: expected 201, got %d: %s", wInit.Code, wInit.Body.String()) + } + initData := decodeAttachData(t, wInit) + fileID, _ := initData["file_id"].(string) + uploadURL, _ := initData["upload_url"].(string) + store.PutObject(context.Background(), "test-bucket", keyFromFakeUploadURL(t, "test-bucket", uploadURL), "image/png", pngBytes) //nolint:errcheck + + // User B tries to complete A's upload as their own avatar. + wComplete := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/users/me/avatar/complete-upload", tokB, map[string]any{ + "file_id": fileID, + })) + if wComplete.Code != http.StatusNotFound { + t.Fatalf("complete as user B: expected 404 (owner mismatch), got %d: %s", wComplete.Code, wComplete.Body.String()) + } + + // User A can still complete their own upload afterwards. + wCompleteA := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/users/me/avatar/complete-upload", tokA, map[string]any{ + "file_id": fileID, + })) + if wCompleteA.Code != http.StatusOK { + t.Fatalf("complete as user A: expected 200, got %d: %s", wCompleteA.Code, wCompleteA.Body.String()) + } +} + +// TestAvatarUpload_ActualBytesExceedDeclaredSize_RejectedAtComplete proves +// that a client can't bypass the size cap by declaring a small file_size at +// initiate time and then PUTting more bytes directly to the presigned URL — +// CompleteAvatarUpload re-checks the actual downloaded object size before +// doing any decode work on it. +func TestAvatarUpload_ActualBytesExceedDeclaredSize_RejectedAtComplete(t *testing.T) { + userID := uuid.New() + store := newFakeStorageClient() + r, userRepo := buildAvatarTestRouter(newFakeAttachmentRepo(), store) + if err := userRepo.Create(context.Background(), &userdom.User{ + ID: userID, Username: "avatartester4", FullName: "Avatar Tester 4", Role: userdom.RoleUser, + }); err != nil { + t.Fatalf("seed user: %v", err) + } + tok := issueAttachToken(t, userID.String()) + + // Declare a small, well-under-the-cap size at initiate time. + wInit := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/users/me/avatar/initiate-upload", tok, map[string]any{ + "file_name": "lies.png", + "content_type": "image/png", + "file_size": 1024, + })) + if wInit.Code != http.StatusCreated { + t.Fatalf("initiate: expected 201, got %d: %s", wInit.Code, wInit.Body.String()) + } + initData := decodeAttachData(t, wInit) + fileID, _ := initData["file_id"].(string) + uploadURL, _ := initData["upload_url"].(string) + + // Simulate the client PUTting more bytes than it declared — nothing + // about the presigned URL itself enforces the declared size. + oversized := bytes.Repeat([]byte{0xAB}, attachmentdom.MaxAvatarUploadSize+1024) + store.PutObject(context.Background(), "test-bucket", keyFromFakeUploadURL(t, "test-bucket", uploadURL), "image/png", oversized) //nolint:errcheck + + wComplete := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/users/me/avatar/complete-upload", tok, map[string]any{ + "file_id": fileID, + })) + if wComplete.Code != http.StatusBadRequest { + t.Errorf("complete with oversized actual upload: expected 400, got %d: %s", wComplete.Code, wComplete.Body.String()) + } +} + +// TestAvatarUpload_Project_FullFlow covers the initiate/upload/complete/ +// delete HTTP flow for a project avatar — the self-service user flow is +// covered by TestAvatarUpload_SelfService above, but that leaves the +// project (and agent) owner kinds without any full-router coverage of their +// own; a project avatar exercises the same CompleteAvatarUpload code path +// through a different owner kind and permission-middleware branch +// (PermissionProjectsWrite instead of the always-self "me" routes). +func TestAvatarUpload_Project_FullFlow(t *testing.T) { + store := newFakeStorageClient() + permStore := &projectPermStore{ + globalPerms: []authz.Permission{ + authz.PermissionProjectsRead, + authz.PermissionProjectsWrite, + authz.PermissionProjectsCreate, + }, + } + r := buildProjectAvatarTestRouter(newFakeAttachmentRepo(), store, permStore) + tok := issueProjectToken(t, uuid.NewString()) + + createW := serve(r, authedJSONReq(t.Context(), http.MethodPost, "/api/v1/projects", tok, map[string]any{ + "name": "Avatar Project", + })) + if createW.Code != http.StatusCreated { + t.Fatalf("create project: expected 201, got %d: %s", createW.Code, createW.Body.String()) + } + projectID := projectIDFromCreate(t, createW) + avatarPath := "/api/v1/projects/" + projectID + "/avatar" + + pngBytes := fakePNG(t, 120, 80) + + wInit := serve(r, authedJSONReq(t.Context(), http.MethodPost, avatarPath+"/initiate-upload", tok, map[string]any{ + "file_name": "project.png", + "content_type": "image/png", + "file_size": len(pngBytes), + })) + if wInit.Code != http.StatusCreated { + t.Fatalf("initiate: expected 201, got %d: %s", wInit.Code, wInit.Body.String()) + } + initData := decodeAttachData(t, wInit) + fileID, _ := initData["file_id"].(string) + uploadURL, _ := initData["upload_url"].(string) + store.PutObject(context.Background(), "test-bucket", keyFromFakeUploadURL(t, "test-bucket", uploadURL), "image/png", pngBytes) //nolint:errcheck + + wComplete := serve(r, authedJSONReq(t.Context(), http.MethodPost, avatarPath+"/complete-upload", tok, map[string]any{ + "file_id": fileID, + })) + if wComplete.Code != http.StatusOK { + t.Fatalf("complete: expected 200, got %d: %s", wComplete.Code, wComplete.Body.String()) + } + completeData := decodeAttachData(t, wComplete) + if completeData["avatar_url"] == nil || completeData["avatar_url"] == "" { + t.Errorf("expected avatar_url in complete response, got %v", completeData) + } + + wGet := serve(r, authedJSONReq(t.Context(), http.MethodGet, "/api/v1/projects/"+projectID, tok, nil)) + if wGet.Code != http.StatusOK { + t.Fatalf("get project: expected 200, got %d: %s", wGet.Code, wGet.Body.String()) + } + getData := decodeAttachData(t, wGet) + if getData["avatar_url"] == nil || getData["avatar_url"] == "" { + t.Errorf("expected GetProject to report avatar_url after upload, got %v", getData) + } + + wDelete := serve(r, authedJSONReq(t.Context(), http.MethodDelete, avatarPath, tok, nil)) + if wDelete.Code != http.StatusOK { + t.Fatalf("delete avatar: expected 200, got %d: %s", wDelete.Code, wDelete.Body.String()) + } + deleteData := decodeAttachData(t, wDelete) + if deleteData["avatar_url"] != nil { + t.Errorf("expected avatar_url to be cleared after delete, got %v", deleteData["avatar_url"]) + } +} From 545f3bff3593d6b77c210518484cd880dc67da22 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sat, 8 Aug 2026 16:10:44 +0000 Subject: [PATCH 3/3] feat: enhance GetObject method to support maxBytes parameter for bounded reads --- services/api/internal/platform/storage/s3.go | 11 ++++++++--- services/api/internal/platform/storage/storage.go | 14 ++++++++++---- .../internal/service/attachment/avatar_service.go | 13 ++++++++----- services/api/test/integration/attachment_test.go | 7 ++++++- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/services/api/internal/platform/storage/s3.go b/services/api/internal/platform/storage/s3.go index effdbca5..10e5d2a4 100644 --- a/services/api/internal/platform/storage/s3.go +++ b/services/api/internal/platform/storage/s3.go @@ -234,8 +234,9 @@ func (c *S3Client) DeleteObject(ctx context.Context, bucket, key string) error { return nil } -// GetObject downloads an object's full contents into memory. -func (c *S3Client) GetObject(ctx context.Context, bucket, key string) ([]byte, error) { +// GetObject downloads an object's contents into memory, bounded by maxBytes +// (see the Client interface doc — maxBytes <= 0 means unbounded). +func (c *S3Client) GetObject(ctx context.Context, bucket, key string, maxBytes int64) ([]byte, error) { out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), @@ -245,7 +246,11 @@ func (c *S3Client) GetObject(ctx context.Context, bucket, key string) ([]byte, e } defer func() { _ = out.Body.Close() }() - data, err := io.ReadAll(out.Body) + var body io.Reader = out.Body + if maxBytes > 0 { + body = io.LimitReader(out.Body, maxBytes) + } + data, err := io.ReadAll(body) if err != nil { return nil, fmt.Errorf("storage: read object %q: %w", key, err) } diff --git a/services/api/internal/platform/storage/storage.go b/services/api/internal/platform/storage/storage.go index be123af8..0b3f07e8 100644 --- a/services/api/internal/platform/storage/storage.go +++ b/services/api/internal/platform/storage/storage.go @@ -59,11 +59,17 @@ type Client interface { // EnsureBucket creates the bucket if it does not already exist. EnsureBucket(ctx context.Context, bucket string) error - // GetObject downloads an object's full contents. Intended for small - // objects the server needs to process itself (e.g. re-encoding an - // uploaded avatar) — not for client-facing downloads, which should use + // GetObject downloads an object's contents. Intended for small objects + // the server needs to process itself (e.g. re-encoding an uploaded + // avatar) — not for client-facing downloads, which should use // PresignGetObject instead. - GetObject(ctx context.Context, bucket, key string) ([]byte, error) + // + // maxBytes bounds the read itself (not just a post-read length check): + // the underlying stream is wrapped in an io.LimitReader, so a caller + // passing e.g. MaxAvatarUploadSize+1 can never be made to allocate more + // than that many bytes for the returned slice, no matter how large the + // actual stored object is. A maxBytes <= 0 means unbounded. + GetObject(ctx context.Context, bucket, key string, maxBytes int64) ([]byte, error) // PutObject uploads data directly from the server (as opposed to a // client uploading via a presigned URL from PresignPutObject). diff --git a/services/api/internal/service/attachment/avatar_service.go b/services/api/internal/service/attachment/avatar_service.go index b8d2fb81..faecda45 100644 --- a/services/api/internal/service/attachment/avatar_service.go +++ b/services/api/internal/service/attachment/avatar_service.go @@ -98,14 +98,17 @@ func (s *Service) CompleteAvatarUpload(ctx context.Context, in attachmentdom.Ava bucket = s.bucket } - raw, err := s.store.GetObject(ctx, bucket, f.StorageKey) + // The client declares file_size at initiate time, but the presigned PUT + // URL enforces nothing about the actual object it accepts — a client can + // upload more bytes than it declared. Bound the read itself (not just a + // post-read length check) by capping it one byte over the limit: if the + // object is larger, the reader is truncated and len(raw) comes back as + // exactly MaxAvatarUploadSize+1, which still trips the check below — but + // the server never allocates more than that for an oversized object. + raw, err := s.store.GetObject(ctx, bucket, f.StorageKey, attachmentdom.MaxAvatarUploadSize+1) if err != nil { return nil, fmt.Errorf("attachment svc: download avatar upload: %w", err) } - // The client declares file_size at initiate time, but the presigned PUT - // URL enforces nothing about the actual object it accepts — a client can - // upload more bytes than it declared. Re-check the real object here, - // before doing any decode work on it. if len(raw) > attachmentdom.MaxAvatarUploadSize { return nil, attachmentdom.ErrAvatarTooLarge } diff --git a/services/api/test/integration/attachment_test.go b/services/api/test/integration/attachment_test.go index 9ed895cc..ad0d4506 100644 --- a/services/api/test/integration/attachment_test.go +++ b/services/api/test/integration/attachment_test.go @@ -223,13 +223,18 @@ func (c *fakeStorageClient) DeleteObject(_ context.Context, _, key string) error func (c *fakeStorageClient) EnsureBucket(_ context.Context, _ string) error { return nil } -func (c *fakeStorageClient) GetObject(_ context.Context, _, key string) ([]byte, error) { +func (c *fakeStorageClient) GetObject(_ context.Context, _, key string, maxBytes int64) ([]byte, error) { c.mu.Lock() defer c.mu.Unlock() data, ok := c.objects[key] if !ok { return nil, fmt.Errorf("fake storage: object %q not found", key) } + // Mirror S3Client's io.LimitReader truncation so tests exercise the same + // "reader capped, not just length-checked after the fact" behavior. + if maxBytes > 0 && int64(len(data)) > maxBytes { + return data[:maxBytes], nil + } return data, nil }