diff --git a/apps/gateway/src/coord/coord.dto.ts b/apps/gateway/src/coord/coord.dto.ts index 16405f3..9af9deb 100644 --- a/apps/gateway/src/coord/coord.dto.ts +++ b/apps/gateway/src/coord/coord.dto.ts @@ -1,3 +1,5 @@ +// ── File-based coord DTOs (legacy file-system backed) ── + export interface CoordMissionStatusDto { mission: { id: string; @@ -47,3 +49,42 @@ export interface CoordTaskDetailDto { startedAt: string; }; } + +// ── DB-backed coord DTOs ── + +export interface CreateDbMissionDto { + name: string; + description?: string; + projectId?: string; + phase?: string; + milestones?: Record[]; + config?: Record; + status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed'; +} + +export interface UpdateDbMissionDto { + name?: string; + description?: string; + projectId?: string; + phase?: string; + milestones?: Record[]; + config?: Record; + status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed'; +} + +export interface CreateMissionTaskDto { + missionId: string; + taskId?: string; + status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; + description?: string; + notes?: string; + pr?: string; +} + +export interface UpdateMissionTaskDto { + taskId?: string; + status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; + description?: string; + notes?: string; + pr?: string; +} diff --git a/apps/gateway/src/coord/coord.service.ts b/apps/gateway/src/coord/coord.service.ts index 89dfac4..e20111a 100644 --- a/apps/gateway/src/coord/coord.service.ts +++ b/apps/gateway/src/coord/coord.service.ts @@ -1,4 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject } from '@nestjs/common'; +import type { Brain } from '@mosaic/brain'; +import { BRAIN } from '../brain/brain.tokens.js'; import { loadMission, getMissionStatus, @@ -16,6 +18,8 @@ import path from 'node:path'; export class CoordService { private readonly logger = new Logger(CoordService.name); + constructor(@Inject(BRAIN) private readonly brain: Brain) {} + async loadMission(projectPath: string): Promise { try { return await loadMission(projectPath); @@ -70,4 +74,68 @@ export class CoordService { return []; } } + + // ── DB-backed methods for multi-tenant mission management ── + + async getMissionsByUser(userId: string) { + return this.brain.missions.findAllByUser(userId); + } + + async getMissionByIdAndUser(id: string, userId: string) { + return this.brain.missions.findByIdAndUser(id, userId); + } + + async getMissionsByProjectAndUser(projectId: string, userId: string) { + return this.brain.missions.findByProjectAndUser(projectId, userId); + } + + async createDbMission(data: Parameters[0]) { + return this.brain.missions.create(data); + } + + async updateDbMission( + id: string, + userId: string, + data: Parameters[1], + ) { + const existing = await this.brain.missions.findByIdAndUser(id, userId); + if (!existing) return null; + return this.brain.missions.update(id, data); + } + + async deleteDbMission(id: string, userId: string) { + const existing = await this.brain.missions.findByIdAndUser(id, userId); + if (!existing) return false; + return this.brain.missions.remove(id); + } + + // ── DB-backed methods for mission tasks (coord tracking) ── + + async getMissionTasksByMissionAndUser(missionId: string, userId: string) { + return this.brain.missionTasks.findByMissionAndUser(missionId, userId); + } + + async getMissionTaskByIdAndUser(id: string, userId: string) { + return this.brain.missionTasks.findByIdAndUser(id, userId); + } + + async createMissionTask(data: Parameters[0]) { + return this.brain.missionTasks.create(data); + } + + async updateMissionTask( + id: string, + userId: string, + data: Parameters[1], + ) { + const existing = await this.brain.missionTasks.findByIdAndUser(id, userId); + if (!existing) return null; + return this.brain.missionTasks.update(id, data); + } + + async deleteMissionTask(id: string, userId: string) { + const existing = await this.brain.missionTasks.findByIdAndUser(id, userId); + if (!existing) return false; + return this.brain.missionTasks.remove(id); + } } diff --git a/packages/brain/src/brain.ts b/packages/brain/src/brain.ts index 07f4fc9..9c3992a 100644 --- a/packages/brain/src/brain.ts +++ b/packages/brain/src/brain.ts @@ -1,12 +1,14 @@ import type { Db } from '@mosaic/db'; import { createProjectsRepo, type ProjectsRepo } from './projects.js'; import { createMissionsRepo, type MissionsRepo } from './missions.js'; +import { createMissionTasksRepo, type MissionTasksRepo } from './mission-tasks.js'; import { createTasksRepo, type TasksRepo } from './tasks.js'; import { createConversationsRepo, type ConversationsRepo } from './conversations.js'; export interface Brain { projects: ProjectsRepo; missions: MissionsRepo; + missionTasks: MissionTasksRepo; tasks: TasksRepo; conversations: ConversationsRepo; } @@ -15,6 +17,7 @@ export function createBrain(db: Db): Brain { return { projects: createProjectsRepo(db), missions: createMissionsRepo(db), + missionTasks: createMissionTasksRepo(db), tasks: createTasksRepo(db), conversations: createConversationsRepo(db), }; diff --git a/packages/brain/src/index.ts b/packages/brain/src/index.ts index 30599e0..5921577 100644 --- a/packages/brain/src/index.ts +++ b/packages/brain/src/index.ts @@ -11,6 +11,12 @@ export { type Mission, type NewMission, } from './missions.js'; +export { + createMissionTasksRepo, + type MissionTasksRepo, + type MissionTask, + type NewMissionTask, +} from './mission-tasks.js'; export { createTasksRepo, type TasksRepo, type Task, type NewTask } from './tasks.js'; export { createConversationsRepo, diff --git a/packages/brain/src/mission-tasks.ts b/packages/brain/src/mission-tasks.ts new file mode 100644 index 0000000..1f0771a --- /dev/null +++ b/packages/brain/src/mission-tasks.ts @@ -0,0 +1,61 @@ +import { eq, and, type Db, missionTasks } from '@mosaic/db'; + +export type MissionTask = typeof missionTasks.$inferSelect; +export type NewMissionTask = typeof missionTasks.$inferInsert; + +export function createMissionTasksRepo(db: Db) { + return { + async findByMission(missionId: string): Promise { + return db.select().from(missionTasks).where(eq(missionTasks.missionId, missionId)); + }, + + async findByMissionAndUser(missionId: string, userId: string): Promise { + return db + .select() + .from(missionTasks) + .where(and(eq(missionTasks.missionId, missionId), eq(missionTasks.userId, userId))); + }, + + async findById(id: string): Promise { + const rows = await db.select().from(missionTasks).where(eq(missionTasks.id, id)); + return rows[0]; + }, + + async findByIdAndUser(id: string, userId: string): Promise { + const rows = await db + .select() + .from(missionTasks) + .where(and(eq(missionTasks.id, id), eq(missionTasks.userId, userId))); + return rows[0]; + }, + + async create(data: NewMissionTask): Promise { + const rows = await db.insert(missionTasks).values(data).returning(); + return rows[0]!; + }, + + async update(id: string, data: Partial): Promise { + const rows = await db + .update(missionTasks) + .set({ ...data, updatedAt: new Date() }) + .where(eq(missionTasks.id, id)) + .returning(); + return rows[0]; + }, + + async remove(id: string): Promise { + const rows = await db.delete(missionTasks).where(eq(missionTasks.id, id)).returning(); + return rows.length > 0; + }, + + async removeByMission(missionId: string): Promise { + const rows = await db + .delete(missionTasks) + .where(eq(missionTasks.missionId, missionId)) + .returning(); + return rows.length; + }, + }; +} + +export type MissionTasksRepo = ReturnType; diff --git a/packages/brain/src/missions.ts b/packages/brain/src/missions.ts index 362ef86..bc211a7 100644 --- a/packages/brain/src/missions.ts +++ b/packages/brain/src/missions.ts @@ -1,4 +1,4 @@ -import { eq, type Db, missions } from '@mosaic/db'; +import { eq, and, type Db, missions } from '@mosaic/db'; export type Mission = typeof missions.$inferSelect; export type NewMission = typeof missions.$inferInsert; @@ -9,15 +9,34 @@ export function createMissionsRepo(db: Db) { return db.select().from(missions); }, + async findAllByUser(userId: string): Promise { + return db.select().from(missions).where(eq(missions.userId, userId)); + }, + async findById(id: string): Promise { const rows = await db.select().from(missions).where(eq(missions.id, id)); return rows[0]; }, + async findByIdAndUser(id: string, userId: string): Promise { + const rows = await db + .select() + .from(missions) + .where(and(eq(missions.id, id), eq(missions.userId, userId))); + return rows[0]; + }, + async findByProject(projectId: string): Promise { return db.select().from(missions).where(eq(missions.projectId, projectId)); }, + async findByProjectAndUser(projectId: string, userId: string): Promise { + return db + .select() + .from(missions) + .where(and(eq(missions.projectId, projectId), eq(missions.userId, userId))); + }, + async create(data: NewMission): Promise { const rows = await db.insert(missions).values(data).returning(); return rows[0]!; diff --git a/packages/db/drizzle/0001_magical_rattler.sql b/packages/db/drizzle/0001_magical_rattler.sql new file mode 100644 index 0000000..784ff00 --- /dev/null +++ b/packages/db/drizzle/0001_magical_rattler.sql @@ -0,0 +1,29 @@ +CREATE TABLE "mission_tasks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "mission_id" uuid NOT NULL, + "task_id" uuid, + "user_id" text NOT NULL, + "status" text DEFAULT 'not-started' NOT NULL, + "description" text, + "notes" text, + "pr" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "missions" ADD COLUMN "user_id" text;--> statement-breakpoint +ALTER TABLE "missions" ADD COLUMN "phase" text;--> statement-breakpoint +ALTER TABLE "missions" ADD COLUMN "milestones" jsonb;--> statement-breakpoint +ALTER TABLE "missions" ADD COLUMN "config" jsonb;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "ban_reason" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "ban_expires" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "mission_tasks" ADD CONSTRAINT "mission_tasks_mission_id_missions_id_fk" FOREIGN KEY ("mission_id") REFERENCES "public"."missions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mission_tasks" ADD CONSTRAINT "mission_tasks_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mission_tasks" ADD CONSTRAINT "mission_tasks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "mission_tasks_mission_id_idx" ON "mission_tasks" USING btree ("mission_id");--> statement-breakpoint +CREATE INDEX "mission_tasks_task_id_idx" ON "mission_tasks" USING btree ("task_id");--> statement-breakpoint +CREATE INDEX "mission_tasks_user_id_idx" ON "mission_tasks" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "mission_tasks_status_idx" ON "mission_tasks" USING btree ("status");--> statement-breakpoint +ALTER TABLE "missions" ADD CONSTRAINT "missions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "missions_user_id_idx" ON "missions" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json index e8a07da..36dd9d4 100644 --- a/packages/db/drizzle/meta/0001_snapshot.json +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -1,6 +1,6 @@ { - "id": "a519a9b8-5882-4141-82e4-0b35be280738", - "prevId": "ed7dea23-55fa-4f92-9256-7809a1e637f1", + "id": "d1721c50-8da3-4cc5-9542-34d79f335541", + "prevId": "a519a9b8-5882-4141-82e4-0b35be280738", "version": "7", "dialect": "postgresql", "tables": { @@ -833,6 +833,184 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mission_tasks": { + "name": "mission_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mission_id": { + "name": "mission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not-started'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr": { + "name": "pr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mission_tasks_mission_id_idx": { + "name": "mission_tasks_mission_id_idx", + "columns": [ + { + "expression": "mission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_task_id_idx": { + "name": "mission_tasks_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_user_id_idx": { + "name": "mission_tasks_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mission_tasks_status_idx": { + "name": "mission_tasks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mission_tasks_mission_id_missions_id_fk": { + "name": "mission_tasks_mission_id_missions_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "missions", + "columnsFrom": [ + "mission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mission_tasks_task_id_tasks_id_fk": { + "name": "mission_tasks_task_id_tasks_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mission_tasks_user_id_users_id_fk": { + "name": "mission_tasks_user_id_users_id_fk", + "tableFrom": "mission_tasks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.missions": { "name": "missions", "schema": "", @@ -869,6 +1047,30 @@ "primaryKey": false, "notNull": false }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestones": { + "name": "milestones", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "metadata": { "name": "metadata", "type": "jsonb", @@ -905,6 +1107,21 @@ "concurrently": false, "method": "btree", "with": {} + }, + "missions_user_id_idx": { + "name": "missions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, "foreignKeys": { @@ -920,6 +1137,19 @@ ], "onDelete": "set null", "onUpdate": "no action" + }, + "missions_user_id_users_id_fk": { + "name": "missions_user_id_users_id_fk", + "tableFrom": "missions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1705,6 +1935,25 @@ "notNull": true, "default": "'member'" }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index dc350de..855cc94 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1773368153122, "tag": "0000_loud_ezekiel_stane", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1773602195609, + "tag": "0001_magical_rattler", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index d491e85..7e65b83 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -25,6 +25,9 @@ export const users = pgTable('users', { emailVerified: boolean('email_verified').notNull().default(false), image: text('image'), role: text('role').notNull().default('member'), + banned: boolean('banned').default(false), + banReason: text('ban_reason'), + banExpires: timestamp('ban_expires', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); @@ -95,11 +98,18 @@ export const missions = pgTable( .notNull() .default('planning'), projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), + phase: text('phase'), + milestones: jsonb('milestones').$type[]>(), + config: jsonb('config'), metadata: jsonb('metadata'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, - (t) => [index('missions_project_id_idx').on(t.projectId)], + (t) => [ + index('missions_project_id_idx').on(t.projectId), + index('missions_user_id_idx').on(t.userId), + ], ); export const tasks = pgTable( @@ -132,6 +142,40 @@ export const tasks = pgTable( ], ); +// ─── Coord Mission Tasks ───────────────────────────────────────────────────── +// Join table tracking coord-managed tasks within a mission. +// Scoped to userId for multi-tenant RBAC isolation. + +export const missionTasks = pgTable( + 'mission_tasks', + { + id: uuid('id').primaryKey().defaultRandom(), + missionId: uuid('mission_id') + .notNull() + .references(() => missions.id, { onDelete: 'cascade' }), + taskId: uuid('task_id').references(() => tasks.id, { onDelete: 'set null' }), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + status: text('status', { + enum: ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'], + }) + .notNull() + .default('not-started'), + description: text('description'), + notes: text('notes'), + pr: text('pr'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + index('mission_tasks_mission_id_idx').on(t.missionId), + index('mission_tasks_task_id_idx').on(t.taskId), + index('mission_tasks_user_id_idx').on(t.userId), + index('mission_tasks_status_idx').on(t.status), + ], +); + export const events = pgTable( 'events', {