Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/api/drizzle/migrations/0073_drive_sync.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- 0073 — Google Drive folder sync (Phase 10, core).
-- Adds a per-member encrypted Drive refresh token and two tables:
-- drive_folder_links — a Mnema folder ⇄ Google Drive folder pairing + settings
-- drive_file_mappings — per-file idempotency + conflict tracking
-- Core migration (folders/docs/attachments are core tables) — applies on self-host.
-- Idempotent (IF NOT EXISTS everywhere); a re-run on restart is safe.

-- Per-member encrypted Google Drive refresh token (mirrors calendar_refresh_token).
ALTER TABLE "workspace_members" ADD COLUMN IF NOT EXISTS "drive_refresh_token" text;

CREATE TABLE IF NOT EXISTS "drive_folder_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"workspace_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE CASCADE,
"folder_id" uuid NOT NULL REFERENCES "folders"("id") ON DELETE CASCADE,
"drive_folder_id" text NOT NULL,
"drive_folder_name" text,
"connected_by" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"direction" text NOT NULL DEFAULT 'both',
"accepted_types" text[] NOT NULL DEFAULT ARRAY[]::text[],
"conflict_policy" text NOT NULL DEFAULT 'manual',
"last_synced_at" timestamptz,
"status" text NOT NULL DEFAULT 'active',
"error_message" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS "drive_links_workspace_idx" ON "drive_folder_links" ("workspace_id");
CREATE UNIQUE INDEX IF NOT EXISTS "drive_links_folder_unique" ON "drive_folder_links" ("folder_id");
CREATE UNIQUE INDEX IF NOT EXISTS "drive_links_ws_drivefolder_unique" ON "drive_folder_links" ("workspace_id", "drive_folder_id");

CREATE TABLE IF NOT EXISTS "drive_file_mappings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"link_id" uuid NOT NULL REFERENCES "drive_folder_links"("id") ON DELETE CASCADE,
"drive_file_id" text NOT NULL,
"drive_name" text,
"doc_id" uuid REFERENCES "docs"("id") ON DELETE SET NULL,
"attachment_id" uuid REFERENCES "attachments"("id") ON DELETE SET NULL,
"drive_md5" text,
"content_hash" text,
"drive_modified_at" timestamptz,
"mnema_modified_at" timestamptz,
"sync_state" text NOT NULL DEFAULT 'synced',
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS "drive_map_link_idx" ON "drive_file_mappings" ("link_id");
CREATE UNIQUE INDEX IF NOT EXISTS "drive_map_link_file_unique" ON "drive_file_mappings" ("link_id", "drive_file_id");
CREATE INDEX IF NOT EXISTS "drive_map_doc_idx" ON "drive_file_mappings" ("doc_id");
CREATE INDEX IF NOT EXISTS "drive_map_attachment_idx" ON "drive_file_mappings" ("attachment_id");
7 changes: 7 additions & 0 deletions apps/api/drizzle/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,13 @@
"when": 1784900000000,
"tag": "0070_task_comments",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1785000000000,
"tag": "0073_drive_sync",
"breakpoints": true
}
]
}
11 changes: 6 additions & 5 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@
"@workos-inc/node": "^10.7.0",
"ai": "^6.0.221",
"bullmq": "5.76.8",
"chart.js": "^4.4.7",
"diff": "^9.0.0",
"dompurify": "3.4.11",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fastify": "^5.10.0",
"fastify-plugin": "^5.0.0",
"googleapis": "^173.0.0",
"gpt-tokenizer": "^3.4.0",
"graphology": "^0.26.0",
"graphology-communities-louvain": "^2.0.2",
Expand All @@ -68,6 +70,7 @@
"juice": "^11.1.1",
"mammoth": "^1.12.0",
"marked": "^18.0.5",
"mdast2docx": "1.6.1",
"mermaid": "11.16.0",
"mime-types": "^3.0.2",
"nanoid": "^5.1.16",
Expand All @@ -77,16 +80,14 @@
"pino-pretty": "^13.1.3",
"playwright": "^1.61.1",
"postgres": "^3.4.0",
"remark-parse": "^11.0.0",
"resend": "^6.17.1",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"unified": "^11.0.5",
"voyageai": "^0.4.0",
"yjs": "^13.6.31",
"zod": "^3.23.0",
"mdast2docx": "1.6.1",
"unified": "^11.0.5",
"remark-parse": "^11.0.0",
"chart.js": "^4.4.7"
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^22.20.1",
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,21 @@ const envSchema = z.object({
GOOGLE_CALENDAR_CLIENT_SECRET: z.string().min(1).optional(),
GOOGLE_CALENDAR_REDIRECT_URI: z.string().url().optional(),

// Phase 10 — Google Drive folder sync (optional; drive routes return 503 when
// unset). Bring-your-own OAuth client, same shape as calendar above. The
// redirect URI must equal the /api/drive/callback URL and be registered as an
// authorised redirect URI in Google Cloud Console.
GOOGLE_DRIVE_CLIENT_ID: z.string().min(1).optional(),
GOOGLE_DRIVE_CLIENT_SECRET: z.string().min(1).optional(),
GOOGLE_DRIVE_REDIRECT_URI: z.string().url().optional(),
// 'drive.file' (least privilege — Mnema only sees files it created or the user
// explicitly opens via the picker) or 'drive' (full — lets users pull
// pre-existing Drive folders; requires Google's restricted-scope review to ship
// to real users in production).
GOOGLE_DRIVE_SCOPE: z.enum(['drive.file', 'drive']).default('drive.file'),
// Skip Drive files larger than this many megabytes when syncing.
DRIVE_SYNC_MAX_FILE_MB: z.coerce.number().int().positive().default(50),

// Phase C — internal meeting-bot controller (the express service that asks
// Recall to send the bot). Defaults to the Docker-network address; the API
// POSTs /join here when an admitted meeting is starting soon.
Expand Down
69 changes: 69 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export const workspaceMembers = pgTable(
joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(),
// Phase C — encrypted Google Calendar refresh token (per member).
calendarRefreshToken: text('calendar_refresh_token'),
// Phase 10 — encrypted Google Drive refresh token (per member).
driveRefreshToken: text('drive_refresh_token'),
},
(table) => ({
pk: primaryKey({ columns: [table.workspaceId, table.userId] }),
Expand Down Expand Up @@ -1681,6 +1683,73 @@ export const attachments = pgTable(
}),
);

// ─── GOOGLE DRIVE SYNC ────────────────────────────────────────────────────────
// Phase 10 — links a Mnema folder to a Google Drive folder and tracks per-file
// sync state. Core-only: references folders/docs/attachments/users/workspaces,
// no enterprise coupling. The encrypted refresh token lives on workspace_members
// (drive_refresh_token, above); these two tables hold the links + file mappings.

export const driveFolderLinks = pgTable(
'drive_folder_links',
{
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
// The Mnema folder that mirrors a Drive folder.
folderId: uuid('folder_id').notNull().references(() => folders.id, { onDelete: 'cascade' }),
// The Google Drive folder id + display name (for the UI).
driveFolderId: text('drive_folder_id').notNull(),
driveFolderName: text('drive_folder_name'),
// Whose encrypted Drive token drives the sync for this link.
connectedBy: uuid('connected_by').notNull().references(() => users.id, { onDelete: 'cascade' }),
// 'pull' (Drive→Mnema), 'push' (Mnema→Drive), or 'both'.
direction: text('direction').notNull().default('both'),
// Allow-listed extensions synced (lowercase, no dot). Empty = built-in defaults.
acceptedTypes: text('accepted_types').array().notNull().default(sql`ARRAY[]::text[]`),
// 'lww' (last-writer-wins by modified time) or 'manual' (flag conflicts, never overwrite).
conflictPolicy: text('conflict_policy').notNull().default('manual'),
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
// 'active' | 'paused' | 'error'
status: text('status').notNull().default('active'),
errorMessage: text('error_message'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
workspaceIdx: index('drive_links_workspace_idx').on(table.workspaceId),
folderUnique: uniqueIndex('drive_links_folder_unique').on(table.folderId),
driveFolderUnique: uniqueIndex('drive_links_ws_drivefolder_unique').on(table.workspaceId, table.driveFolderId),
}),
);

export const driveFileMappings = pgTable(
'drive_file_mappings',
{
id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
linkId: uuid('link_id').notNull().references(() => driveFolderLinks.id, { onDelete: 'cascade' }),
// The Google Drive file id.
driveFileId: text('drive_file_id').notNull(),
driveName: text('drive_name'),
// Where it landed in Mnema — a doc (md/txt) or an attachment (binary). One is set.
docId: uuid('doc_id').references(() => docs.id, { onDelete: 'set null' }),
attachmentId: uuid('attachment_id').references(() => attachments.id, { onDelete: 'set null' }),
// Drive checksum (md5Checksum) + the Mnema-side content hash, for change detection.
driveMd5: text('drive_md5'),
contentHash: text('content_hash'),
driveModifiedAt: timestamp('drive_modified_at', { withTimezone: true }),
mnemaModifiedAt: timestamp('mnema_modified_at', { withTimezone: true }),
// 'synced' | 'pending_push' | 'pending_pull' | 'conflict'
syncState: text('sync_state').notNull().default('synced'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
linkIdx: index('drive_map_link_idx').on(table.linkId),
driveFileUnique: uniqueIndex('drive_map_link_file_unique').on(table.linkId, table.driveFileId),
docIdx: index('drive_map_doc_idx').on(table.docId),
attachmentIdx: index('drive_map_attachment_idx').on(table.attachmentId),
}),
);

// ─── KNOWLEDGE GRAPH ──────────────────────────────────────────────────────────

export const graphNodes = pgTable(
Expand Down
Loading
Loading