diff --git a/packages/db/src/migrations/0015_project_color_archived.sql b/packages/db/src/migrations/0015_project_color_archived.sql new file mode 100644 index 00000000..8fdbb52e --- /dev/null +++ b/packages/db/src/migrations/0015_project_color_archived.sql @@ -0,0 +1,2 @@ +ALTER TABLE "projects" ADD COLUMN "color" text; +ALTER TABLE "projects" ADD COLUMN "archived_at" timestamp with time zone; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index ad3c61ad..fe4f6ac3 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -99,6 +99,20 @@ "when": 1771623691139, "tag": "0013_dashing_wasp", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1771691806349, + "tag": "0014_many_mikhail_rasputin", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1771865100000, + "tag": "0015_project_color_archived", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/projects.ts b/packages/db/src/schema/projects.ts index 9a0eec1b..d532a0f5 100644 --- a/packages/db/src/schema/projects.ts +++ b/packages/db/src/schema/projects.ts @@ -14,6 +14,8 @@ export const projects = pgTable( status: text("status").notNull().default("backlog"), leadAgentId: uuid("lead_agent_id").references(() => agents.id), targetDate: date("target_date"), + color: text("color"), + archivedAt: timestamp("archived_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 781004f4..11082792 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,6 +1,15 @@ export const COMPANY_STATUSES = ["active", "paused", "archived"] as const; export type CompanyStatus = (typeof COMPANY_STATUSES)[number]; +export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const; +export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number]; + +export const DEPLOYMENT_EXPOSURES = ["private", "public"] as const; +export type DeploymentExposure = (typeof DEPLOYMENT_EXPOSURES)[number]; + +export const AUTH_BASE_URL_MODES = ["auto", "explicit"] as const; +export type AuthBaseUrlMode = (typeof AUTH_BASE_URL_MODES)[number]; + export const AGENT_STATUSES = [ "active", "paused", @@ -59,6 +68,19 @@ export const PROJECT_STATUSES = [ ] as const; export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; +export const PROJECT_COLORS = [ + "#6366f1", // indigo + "#8b5cf6", // violet + "#ec4899", // pink + "#ef4444", // red + "#f97316", // orange + "#eab308", // yellow + "#22c55e", // green + "#14b8a6", // teal + "#06b6d4", // cyan + "#3b82f6", // blue +] as const; + export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy"] as const; export type ApprovalType = (typeof APPROVAL_TYPES)[number]; @@ -124,3 +146,34 @@ export const LIVE_EVENT_TYPES = [ "activity.logged", ] as const; export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number]; + +export const PRINCIPAL_TYPES = ["user", "agent"] as const; +export type PrincipalType = (typeof PRINCIPAL_TYPES)[number]; + +export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const; +export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number]; + +export const INSTANCE_USER_ROLES = ["instance_admin"] as const; +export type InstanceUserRole = (typeof INSTANCE_USER_ROLES)[number]; + +export const INVITE_TYPES = ["company_join", "bootstrap_ceo"] as const; +export type InviteType = (typeof INVITE_TYPES)[number]; + +export const INVITE_JOIN_TYPES = ["human", "agent", "both"] as const; +export type InviteJoinType = (typeof INVITE_JOIN_TYPES)[number]; + +export const JOIN_REQUEST_TYPES = ["human", "agent"] as const; +export type JoinRequestType = (typeof JOIN_REQUEST_TYPES)[number]; + +export const JOIN_REQUEST_STATUSES = ["pending_approval", "approved", "rejected"] as const; +export type JoinRequestStatus = (typeof JOIN_REQUEST_STATUSES)[number]; + +export const PERMISSION_KEYS = [ + "agents:create", + "users:invite", + "users:manage_permissions", + "tasks:assign", + "tasks:assign_scope", + "joins:approve", +] as const; +export type PermissionKey = (typeof PERMISSION_KEYS)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d25ceb88..d72037c7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,8 @@ export { COMPANY_STATUSES, + DEPLOYMENT_MODES, + DEPLOYMENT_EXPOSURES, + AUTH_BASE_URL_MODES, AGENT_STATUSES, AGENT_ADAPTER_TYPES, AGENT_ROLES, @@ -8,6 +11,7 @@ export { GOAL_LEVELS, GOAL_STATUSES, PROJECT_STATUSES, + PROJECT_COLORS, APPROVAL_TYPES, APPROVAL_STATUSES, SECRET_PROVIDERS, @@ -17,7 +21,18 @@ export { WAKEUP_TRIGGER_DETAILS, WAKEUP_REQUEST_STATUSES, LIVE_EVENT_TYPES, + PRINCIPAL_TYPES, + MEMBERSHIP_STATUSES, + INSTANCE_USER_ROLES, + INVITE_TYPES, + INVITE_JOIN_TYPES, + JOIN_REQUEST_TYPES, + JOIN_REQUEST_STATUSES, + PERMISSION_KEYS, type CompanyStatus, + type DeploymentMode, + type DeploymentExposure, + type AuthBaseUrlMode, type AgentStatus, type AgentAdapterType, type AgentRole, @@ -35,6 +50,14 @@ export { type WakeupTriggerDetail, type WakeupRequestStatus, type LiveEventType, + type PrincipalType, + type MembershipStatus, + type InstanceUserRole, + type InviteType, + type InviteJoinType, + type JoinRequestType, + type JoinRequestStatus, + type PermissionKey, } from "./constants.js"; export type { @@ -68,6 +91,11 @@ export type { DashboardSummary, ActivityEvent, SidebarBadges, + CompanyMembership, + PrincipalPermissionGrant, + Invite, + JoinRequest, + InstanceUserRoleGrant, EnvBinding, AgentEnvConfig, CompanySecret, @@ -139,9 +167,19 @@ export { createCostEventSchema, updateBudgetSchema, createAssetImageMetadataSchema, + createCompanyInviteSchema, + acceptInviteSchema, + listJoinRequestsQuerySchema, + updateMemberPermissionsSchema, + updateUserCompanyAccessSchema, type CreateCostEvent, type UpdateBudget, type CreateAssetImageMetadata, + type CreateCompanyInvite, + type AcceptInvite, + type ListJoinRequestsQuery, + type UpdateMemberPermissions, + type UpdateUserCompanyAccess, } from "./validators/index.js"; export { API_PREFIX, API } from "./api.js"; @@ -153,6 +191,7 @@ export { databaseConfigSchema, loggingConfigSchema, serverConfigSchema, + authConfigSchema, secretsConfigSchema, storageConfigSchema, storageLocalDiskConfigSchema, @@ -163,6 +202,7 @@ export { type DatabaseConfig, type LoggingConfig, type ServerConfig, + type AuthConfig, type StorageConfig, type StorageLocalDiskConfig, type StorageS3Config, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 897f2940..e3d8a794 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -17,6 +17,8 @@ export interface Project { status: ProjectStatus; leadAgentId: string | null; targetDate: string | null; + color: string | null; + archivedAt: Date | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index ec1ddbea..28280193 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -10,6 +10,8 @@ export const createProjectSchema = z.object({ status: z.enum(PROJECT_STATUSES).optional().default("backlog"), leadAgentId: z.string().uuid().optional().nullable(), targetDate: z.string().optional().nullable(), + color: z.string().optional().nullable(), + archivedAt: z.string().datetime().optional().nullable(), }); export type CreateProject = z.infer; diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index c0b61dcb..c76d7efc 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,7 +1,7 @@ import { eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclip/db"; import { projects, projectGoals, goals } from "@paperclip/db"; -import type { ProjectGoalRef } from "@paperclip/shared"; +import { PROJECT_COLORS, type ProjectGoalRef } from "@paperclip/shared"; type ProjectRow = typeof projects.$inferSelect; @@ -90,6 +90,14 @@ export function projectService(db: Db) { const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); + // Auto-assign a color from the palette if none provided + if (!projectData.color) { + const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId)); + const usedColors = new Set(existing.map((r) => r.color).filter(Boolean)); + const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length]; + projectData.color = nextColor; + } + // Also write goalId to the legacy column (first goal or null) const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 21c014eb..09b9b168 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1,7 +1,6 @@ import { Inbox, CircleDot, - Hexagon, Target, LayoutDashboard, Bot, @@ -9,7 +8,6 @@ import { History, Search, SquarePen, - ShieldCheck, BookOpen, Paperclip, } from "lucide-react"; @@ -17,6 +15,7 @@ import { useQuery } from "@tanstack/react-query"; import { CompanySwitcher } from "./CompanySwitcher"; import { SidebarSection } from "./SidebarSection"; import { SidebarNavItem } from "./SidebarNavItem"; +import { SidebarProjects } from "./SidebarProjects"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { sidebarBadgesApi } from "../api/sidebarBadges"; @@ -82,20 +81,15 @@ export function Sidebar() { /> + + - - diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx new file mode 100644 index 00000000..ea6695ca --- /dev/null +++ b/ui/src/components/SidebarProjects.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { NavLink, useLocation } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronRight, Plus } from "lucide-react"; +import { useCompany } from "../context/CompanyContext"; +import { useDialog } from "../context/DialogContext"; +import { useSidebar } from "../context/SidebarContext"; +import { projectsApi } from "../api/projects"; +import { queryKeys } from "../lib/queryKeys"; +import { cn } from "../lib/utils"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import type { Project } from "@paperclip/shared"; + +export function SidebarProjects() { + const [open, setOpen] = useState(true); + const { selectedCompanyId } = useCompany(); + const { openNewProject } = useDialog(); + const { isMobile, setSidebarOpen } = useSidebar(); + const location = useLocation(); + + const { data: projects } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + // Filter out archived projects + const visibleProjects = (projects ?? []).filter( + (p: Project) => !p.archivedAt + ); + + // Extract current projectId from URL + const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/); + const activeProjectId = projectMatch?.[1] ?? null; + + return ( + +
+
+ + + + Projects + + + +
+
+ + +
+ {visibleProjects.map((project: Project) => ( + { + if (isMobile) setSidebarOpen(false); + }} + className={cn( + "flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors", + activeProjectId === project.id + ? "bg-accent text-foreground" + : "text-foreground/80 hover:bg-accent/50 hover:text-foreground" + )} + > + + {project.name} + + ))} +
+
+
+ ); +}