From 6b105c07c4eb863777ad92f7c02dfa091223fd6f Mon Sep 17 00:00:00 2001 From: MOHAN Date: Mon, 6 Jul 2026 15:37:26 +0530 Subject: [PATCH] feat: Implement multi-tenant architecture with organization support - Added organization model and membership management to support multi-tenancy. - Updated file and folder routes to include organization context for ownership checks. - Integrated Google Drive connection management per organization, ensuring proper authorization and folder structure. - Enhanced user account deletion to prevent removal if the user owns organizations. - Introduced middleware for organization validation and admin role checks. - Created organization admin routes for managing memberships and retrieving statistics. - Implemented token encryption/decryption for secure Google Drive token storage. - Updated serializers to include organization data in user memberships. - Added database migration for new organization and membership tables. --- .../migration.sql | 78 ++++++ prisma/schema.prisma | 137 +++++++---- src/app.js | 9 +- src/middleware/organization.js | 31 +++ src/routes/auth.js | 65 +++-- src/routes/files.js | 34 ++- src/routes/folders.js | 45 ++-- src/routes/organizationAdmin.js | 98 ++++++++ src/routes/organizations.js | 144 +++++++++++ src/routes/retention.js | 7 + src/services/googleDrive.js | 232 +++++++++++++----- src/utils/serializers.js | 16 ++ src/utils/tokenVault.js | 23 ++ 13 files changed, 757 insertions(+), 162 deletions(-) create mode 100644 prisma/migrations/20260706160000_multi_tenant_saas/migration.sql create mode 100644 src/middleware/organization.js create mode 100644 src/routes/organizationAdmin.js create mode 100644 src/routes/organizations.js create mode 100644 src/utils/tokenVault.js diff --git a/prisma/migrations/20260706160000_multi_tenant_saas/migration.sql b/prisma/migrations/20260706160000_multi_tenant_saas/migration.sql new file mode 100644 index 0000000..84a8a01 --- /dev/null +++ b/prisma/migrations/20260706160000_multi_tenant_saas/migration.sql @@ -0,0 +1,78 @@ +CREATE TABLE "Organization" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Membership" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'MEMBER', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "rejectionReason" TEXT, + "driveFolderId" TEXT, + "reviewedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Membership_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "GoogleDriveConnection" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "tokenCiphertext" TEXT NOT NULL, + "storageType" TEXT NOT NULL DEFAULT 'MY_DRIVE', + "sharedDriveId" TEXT, + "rootFolderId" TEXT, + "status" TEXT NOT NULL DEFAULT 'CONNECTED', + "scope" TEXT, + "connectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "GoogleDriveConnection_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "Folder" ADD COLUMN "organizationId" TEXT; +ALTER TABLE "FileAsset" ADD COLUMN "organizationId" TEXT; + +INSERT INTO "Organization" ("id", "code", "name", "status", "updatedAt") +VALUES ('00000000-0000-0000-0000-000000000001', 'METATRON', 'Metatron Drive Legacy', 'ACTIVE', CURRENT_TIMESTAMP); + +INSERT INTO "Membership" ("id", "userId", "organizationId", "role", "status", "driveFolderId", "reviewedAt", "updatedAt") +SELECT "id", "id", '00000000-0000-0000-0000-000000000001', + CASE WHEN "id" = (SELECT "id" FROM "User" ORDER BY "createdAt" ASC LIMIT 1) THEN 'OWNER' ELSE 'MEMBER' END, + + CASE WHEN "isSuspended" THEN 'SUSPENDED' ELSE 'APPROVED' END, + "driveFolderId", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM "User"; + +UPDATE "Folder" SET "organizationId" = '00000000-0000-0000-0000-000000000001'; +UPDATE "FileAsset" SET "organizationId" = '00000000-0000-0000-0000-000000000001'; +ALTER TABLE "Folder" ALTER COLUMN "organizationId" SET NOT NULL; +ALTER TABLE "FileAsset" ALTER COLUMN "organizationId" SET NOT NULL; +ALTER TABLE "User" DROP COLUMN "driveFolderId"; + +DROP INDEX IF EXISTS "Folder_userId_parentId_name_key"; +DROP INDEX IF EXISTS "Folder_userId_parentId_idx"; +DROP INDEX IF EXISTS "FileAsset_userId_folderId_createdAt_idx"; + +CREATE UNIQUE INDEX "Organization_code_key" ON "Organization"("code"); +CREATE INDEX "Organization_status_createdAt_idx" ON "Organization"("status", "createdAt"); +CREATE UNIQUE INDEX "Membership_driveFolderId_key" ON "Membership"("driveFolderId"); +CREATE UNIQUE INDEX "Membership_userId_organizationId_key" ON "Membership"("userId", "organizationId"); +CREATE INDEX "Membership_organizationId_status_createdAt_idx" ON "Membership"("organizationId", "status", "createdAt"); +CREATE INDEX "Membership_userId_status_idx" ON "Membership"("userId", "status"); +CREATE UNIQUE INDEX "GoogleDriveConnection_organizationId_key" ON "GoogleDriveConnection"("organizationId"); +CREATE UNIQUE INDEX "Folder_organizationId_userId_parentId_name_key" ON "Folder"("organizationId", "userId", "parentId", "name"); +CREATE INDEX "Folder_organizationId_userId_parentId_idx" ON "Folder"("organizationId", "userId", "parentId"); +CREATE INDEX "FileAsset_organizationId_userId_folderId_createdAt_idx" ON "FileAsset"("organizationId", "userId", "folderId", "createdAt"); + +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "GoogleDriveConnection" ADD CONSTRAINT "GoogleDriveConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Folder" ADD CONSTRAINT "Folder_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "FileAsset" ADD CONSTRAINT "FileAsset_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ddd62c4..7e1ef69 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,8 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Get a free hosted Postgres database in seconds: `npx create-db` - generator client { provider = "prisma-client-js" } @@ -12,53 +7,105 @@ datasource db { } model User { - id String @id @default(uuid()) - username String @unique - passwordHash String - name String - phone String @unique - email String @unique - gender String - age Int - driveFolderId String @unique - isSuspended Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - folders Folder[] - files FileAsset[] + id String @id @default(uuid()) + username String @unique + passwordHash String + name String + phone String @unique + email String @unique + gender String + age Int + isSuspended Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + memberships Membership[] + folders Folder[] + files FileAsset[] @@index([isSuspended, createdAt]) } -model Folder { - id String @id @default(uuid()) - name String - driveFolderId String @unique - scopeKey String? @unique - userId String - parentId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade) - children Folder[] @relation("FolderTree") - files FileAsset[] +model Organization { + id String @id @default(uuid()) + code String @unique + name String + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + memberships Membership[] + driveConnection GoogleDriveConnection? + folders Folder[] + files FileAsset[] - @@unique([userId, parentId, name]) - @@index([userId, parentId]) + @@index([status, createdAt]) +} + +model Membership { + id String @id @default(uuid()) + userId String + organizationId String + role String @default("MEMBER") + status String @default("PENDING") + rejectionReason String? + driveFolderId String? @unique + reviewedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + @@unique([userId, organizationId]) + @@index([organizationId, status, createdAt]) + @@index([userId, status]) +} + +model GoogleDriveConnection { + id String @id @default(uuid()) + organizationId String @unique + tokenCiphertext String + storageType String @default("MY_DRIVE") + sharedDriveId String? + rootFolderId String? + status String @default("CONNECTED") + scope String? + connectedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) +} + +model Folder { + id String @id @default(uuid()) + name String + driveFolderId String @unique + scopeKey String? @unique + userId String + organizationId String + parentId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade) + children Folder[] @relation("FolderTree") + files FileAsset[] + + @@unique([organizationId, userId, parentId, name]) + @@index([organizationId, userId, parentId]) } model FileAsset { - id String @id @default(uuid()) - originalName String - driveFileId String @unique - mimeType String - sizeBytes BigInt - userId String - folderId String? - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) + id String @id @default(uuid()) + originalName String + driveFileId String @unique + mimeType String + sizeBytes BigInt + userId String + organizationId String + folderId String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) - @@index([userId, folderId, createdAt]) + @@index([organizationId, userId, folderId, createdAt]) } diff --git a/src/app.js b/src/app.js index a1790db..294afc3 100644 --- a/src/app.js +++ b/src/app.js @@ -5,12 +5,15 @@ import morgan from "morgan"; import { rateLimit } from "express-rate-limit"; import { config } from "./config.js"; import { requireAuth } from "./middleware/auth.js"; +import { requireOrganization, requireOrganizationAdmin } from "./middleware/organization.js"; import { errorHandler, notFound } from "./middleware/errorHandler.js"; import authRoutes from "./routes/auth.js"; import folderRoutes from "./routes/folders.js"; import fileRoutes from "./routes/files.js"; import retentionRoutes from "./routes/retention.js"; import adminRoutes from "./routes/admin.js"; +import organizationRoutes from "./routes/organizations.js"; +import organizationAdminRoutes from "./routes/organizationAdmin.js"; export const app = express(); app.disable("x-powered-by"); @@ -30,9 +33,11 @@ const uploadLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 500, standard const adminLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 50, standardHeaders: "draft-8", legacyHeaders: false }); app.use("/api/auth", authLimiter, authRoutes); app.use("/api/admin", adminLimiter, adminRoutes); -app.use("/api/folders", requireAuth, folderRoutes); +app.use("/api/organizations", authLimiter, organizationRoutes); +app.use("/api/org-admin", adminLimiter, requireAuth, requireOrganization, requireOrganizationAdmin, organizationAdminRoutes); +app.use("/api/folders", requireAuth, requireOrganization, folderRoutes); app.use("/api/files/upload", uploadLimiter); -app.use("/api/files", requireAuth, fileRoutes); +app.use("/api/files", requireAuth, requireOrganization, fileRoutes); app.use("/api/retention", requireAuth, retentionRoutes); app.use(notFound); diff --git a/src/middleware/organization.js b/src/middleware/organization.js new file mode 100644 index 0000000..1b0e1e8 --- /dev/null +++ b/src/middleware/organization.js @@ -0,0 +1,31 @@ +import { prisma } from "../db.js"; + +export async function requireOrganization(request, response, next) { + try { + const organizationId = String(request.headers["x-organization-id"] || ""); + if (!organizationId) return response.status(400).json({ error: "Select an organization" }); + const membership = await prisma.membership.findUnique({ + where: { userId_organizationId: { userId: request.user.id, organizationId } }, + include: { organization: true }, + }); + if (!membership) return response.status(403).json({ error: "You do not belong to this organization" }); + if (membership.status !== "APPROVED") { + return response.status(403).json({ error: `Membership is ${membership.status.toLowerCase()}` }); + } + if (membership.organization.status !== "ACTIVE") { + return response.status(403).json({ error: "Organization is not active" }); + } + request.membership = membership; + request.organization = membership.organization; + next(); + } catch (error) { + next(error); + } +} + +export function requireOrganizationAdmin(request, response, next) { + if (!request.membership || !["OWNER", "ADMIN"].includes(request.membership.role)) { + return response.status(403).json({ error: "Organization administrator access required" }); + } + next(); +} diff --git a/src/routes/auth.js b/src/routes/auth.js index 5353794..b46b1b9 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -4,7 +4,6 @@ import jwt from "jsonwebtoken"; import { z } from "zod"; import { prisma } from "../db.js"; import { config } from "../config.js"; -import { createUserRootFolder, deleteDriveItem } from "../services/googleDrive.js"; import { asyncHandler } from "../utils/asyncHandler.js"; import { HttpError } from "../utils/httpError.js"; import { publicUser } from "../utils/serializers.js"; @@ -12,7 +11,7 @@ import { requireAuth } from "../middleware/auth.js"; const router = Router(); const usernameRule = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9._-]+$/); -const registerSchema = z.object({ +const profileSchema = z.object({ username: usernameRule, password: z.string().min(8).max(128), name: z.string().trim().min(2).max(80), @@ -21,46 +20,72 @@ const registerSchema = z.object({ gender: z.enum(["Male", "Female", "Non-binary", "Prefer not to say"]), age: z.coerce.number().int().min(13).max(120), }); +const registerSchema = profileSchema.extend({ organizationCode: z.string().trim().toUpperCase().min(4).max(20) }); const loginSchema = z.object({ username: usernameRule, password: z.string().min(1).max(128) }); function issueToken(userId) { return jwt.sign({}, config.JWT_SECRET, { subject: userId, expiresIn: config.JWT_EXPIRES_IN }); } +function membershipInclude() { + return { memberships: { include: { organization: true }, orderBy: { createdAt: "asc" } } }; +} + router.post("/register", asyncHandler(async (request, response) => { const input = registerSchema.parse(request.body); + const organization = await prisma.organization.findUnique({ where: { code: input.organizationCode } }); + if (!organization || organization.status !== "ACTIVE") throw new HttpError(404, "Organization not found"); const duplicate = await prisma.user.findFirst({ where: { OR: [{ username: input.username }, { email: input.email }, { phone: input.phone }] }, select: { id: true }, }); - if (duplicate) throw new HttpError(409, "Username, email, or phone is already registered"); - - let driveFolderId; - try { - driveFolderId = await createUserRootFolder(input.name, input.username); - const { password, ...profile } = input; - const user = await prisma.user.create({ - data: { ...profile, passwordHash: await bcrypt.hash(password, 12), driveFolderId }, - }); - response.status(201).json({ token: issueToken(user.id), user: publicUser(user) }); - } catch (error) { - if (driveFolderId) await deleteDriveItem(driveFolderId).catch(() => {}); - throw error; - } + if (duplicate) throw new HttpError(409, "An account already exists. Sign in and join this organization instead."); + const { password, organizationCode, ...profile } = input; + const user = await prisma.user.create({ + data: { + ...profile, + passwordHash: await bcrypt.hash(password, 12), + memberships: { create: { organizationId: organization.id, status: "PENDING", role: "MEMBER" } }, + }, + include: membershipInclude(), + }); + response.status(202).json({ + message: "Application submitted. You can sign in after an organization administrator approves it.", + user: publicUser(user), + }); })); router.post("/login", asyncHandler(async (request, response) => { const input = loginSchema.parse(request.body); - const user = await prisma.user.findUnique({ where: { username: input.username } }); + const user = await prisma.user.findUnique({ where: { username: input.username }, include: membershipInclude() }); if (!user || !(await bcrypt.compare(input.password, user.passwordHash))) { throw new HttpError(401, "Invalid username or password"); } if (user.isSuspended) throw new HttpError(403, "Account is suspended. Contact the administrator."); + if (!user.memberships.some((membership) => membership.status === "APPROVED" && membership.organization.status === "ACTIVE")) { + throw new HttpError(403, "Your organization membership is awaiting approval"); + } response.json({ token: issueToken(user.id), user: publicUser(user) }); })); -router.get("/me", requireAuth, (request, response) => { - response.json({ user: publicUser(request.user) }); -}); +router.get("/me", requireAuth, asyncHandler(async (request, response) => { + const user = await prisma.user.findUnique({ where: { id: request.user.id }, include: membershipInclude() }); + response.json({ user: publicUser(user) }); +})); + +router.post("/join", requireAuth, asyncHandler(async (request, response) => { + const { organizationCode } = z.object({ organizationCode: z.string().trim().toUpperCase().min(4).max(20) }).parse(request.body); + const organization = await prisma.organization.findUnique({ where: { code: organizationCode } }); + if (!organization || organization.status !== "ACTIVE") throw new HttpError(404, "Organization not found"); + const existing = await prisma.membership.findUnique({ + where: { userId_organizationId: { userId: request.user.id, organizationId: organization.id } }, + }); + if (existing) throw new HttpError(409, `Membership is already ${existing.status.toLowerCase()}`); + const membership = await prisma.membership.create({ + data: { userId: request.user.id, organizationId: organization.id, status: "PENDING", role: "MEMBER" }, + include: { organization: true }, + }); + response.status(202).json({ message: "Join request submitted", membership }); +})); export default router; diff --git a/src/routes/files.js b/src/routes/files.js index f58022b..09663d5 100644 --- a/src/routes/files.js +++ b/src/routes/files.js @@ -6,12 +6,14 @@ import { uploadSingle } from "../middleware/upload.js"; import { asyncHandler } from "../utils/asyncHandler.js"; import { HttpError } from "../utils/httpError.js"; import { serializeFile } from "../utils/serializers.js"; -import { deleteDriveItem, streamFile, uploadFile } from "../services/googleDrive.js"; +import { deleteDriveItem, ensureMembershipRoot, streamFile, uploadFile } from "../services/googleDrive.js"; const router = Router(); -async function ownedFile(userId, id) { - const file = await prisma.fileAsset.findFirst({ where: { id, userId } }); +async function ownedFile(request, id) { + const file = await prisma.fileAsset.findFirst({ + where: { id, userId: request.user.id, organizationId: request.organization.id }, + }); if (!file) throw new HttpError(404, "File not found"); return file; } @@ -19,11 +21,14 @@ async function ownedFile(userId, id) { router.get("/", asyncHandler(async (request, response) => { const folderId = request.query.folderId ? z.string().uuid().parse(request.query.folderId) : null; if (folderId) { - const folder = await prisma.folder.findFirst({ where: { id: folderId, userId: request.user.id }, select: { id: true } }); + const folder = await prisma.folder.findFirst({ + where: { id: folderId, userId: request.user.id, organizationId: request.organization.id }, + select: { id: true }, + }); if (!folder) throw new HttpError(404, "Folder not found"); } const files = await prisma.fileAsset.findMany({ - where: { userId: request.user.id, folderId }, + where: { userId: request.user.id, organizationId: request.organization.id, folderId }, orderBy: { createdAt: "desc" }, }); response.json({ files: files.map(serializeFile) }); @@ -34,13 +39,17 @@ router.post("/upload", uploadSingle, asyncHandler(async (request, response) => { let uploaded; try { const folderId = request.body.folderId ? z.string().uuid().parse(request.body.folderId) : null; - let parentDriveFolderId = request.user.driveFolderId; + let parentDriveFolderId = await ensureMembershipRoot(request.membership.id); + if (!parentDriveFolderId) throw new HttpError(409, "Organization administrator must connect Google Drive first"); if (folderId) { - const folder = await prisma.folder.findFirst({ where: { id: folderId, userId: request.user.id } }); + const folder = await prisma.folder.findFirst({ + where: { id: folderId, userId: request.user.id, organizationId: request.organization.id }, + }); if (!folder) throw new HttpError(404, "Folder not found"); parentDriveFolderId = folder.driveFolderId; } uploaded = await uploadFile({ + organizationId: request.organization.id, filePath: request.file.path, originalName: request.file.originalname, mimeType: request.file.mimetype || "application/octet-stream", @@ -53,12 +62,13 @@ router.post("/upload", uploadSingle, asyncHandler(async (request, response) => { mimeType: request.file.mimetype || uploaded.mimeType || "application/octet-stream", sizeBytes: BigInt(request.file.size), userId: request.user.id, + organizationId: request.organization.id, folderId, }, }); response.status(201).json({ file: serializeFile(file) }); } catch (error) { - if (uploaded?.id) await deleteDriveItem(uploaded.id).catch(() => {}); + if (uploaded?.id) await deleteDriveItem(request.organization.id, uploaded.id).catch(() => {}); throw error; } finally { await fs.unlink(request.file.path).catch(() => {}); @@ -66,15 +76,15 @@ router.post("/upload", uploadSingle, asyncHandler(async (request, response) => { })); router.get("/:id/content", asyncHandler(async (request, response) => { - const file = await ownedFile(request.user.id, z.string().uuid().parse(request.params.id)); + const file = await ownedFile(request, z.string().uuid().parse(request.params.id)); response.setHeader("Content-Type", file.mimeType); response.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.originalName)}`); - await streamFile(file.driveFileId, response); + await streamFile(request.organization.id, file.driveFileId, response); })); router.delete("/:id", asyncHandler(async (request, response) => { - const file = await ownedFile(request.user.id, z.string().uuid().parse(request.params.id)); - await deleteDriveItem(file.driveFileId); + const file = await ownedFile(request, z.string().uuid().parse(request.params.id)); + await deleteDriveItem(request.organization.id, file.driveFileId); await prisma.fileAsset.delete({ where: { id: file.id } }); response.status(204).end(); })); diff --git a/src/routes/folders.js b/src/routes/folders.js index dc44714..0bc8ecc 100644 --- a/src/routes/folders.js +++ b/src/routes/folders.js @@ -3,27 +3,29 @@ import { z } from "zod"; import { prisma } from "../db.js"; import { asyncHandler } from "../utils/asyncHandler.js"; import { HttpError } from "../utils/httpError.js"; -import { createChildFolder, deleteDriveItem, renameDriveItem } from "../services/googleDrive.js"; +import { createChildFolder, deleteDriveItem, ensureMembershipRoot, renameDriveItem } from "../services/googleDrive.js"; const router = Router(); const folderName = z.string().trim().min(1).max(120).refine((name) => !/[\\/:*?"<>|]/.test(name), "Folder name contains invalid characters"); const optionalParent = z.string().uuid().nullable().optional(); -function folderScope(userId, parentId, name) { - return `${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`; +function folderScope(organizationId, userId, parentId, name) { + return `${organizationId}:${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`; } -async function ownedFolder(userId, id) { - const folder = await prisma.folder.findFirst({ where: { id, userId } }); +async function ownedFolder(request, id) { + const folder = await prisma.folder.findFirst({ + where: { id, userId: request.user.id, organizationId: request.organization.id }, + }); if (!folder) throw new HttpError(404, "Folder not found"); return folder; } router.get("/", asyncHandler(async (request, response) => { const parentId = request.query.parentId ? z.string().uuid().parse(request.query.parentId) : null; - if (parentId) await ownedFolder(request.user.id, parentId); + if (parentId) await ownedFolder(request, parentId); const folders = await prisma.folder.findMany({ - where: { userId: request.user.id, parentId }, + where: { userId: request.user.id, organizationId: request.organization.id, parentId }, orderBy: { name: "asc" }, include: { _count: { select: { children: true, files: true } } }, }); @@ -32,41 +34,50 @@ router.get("/", asyncHandler(async (request, response) => { router.post("/", asyncHandler(async (request, response) => { const input = z.object({ name: folderName, parentId: optionalParent }).parse(request.body); - const parent = input.parentId ? await ownedFolder(request.user.id, input.parentId) : null; - const scopeKey = folderScope(request.user.id, input.parentId, input.name); + const parent = input.parentId ? await ownedFolder(request, input.parentId) : null; + const scopeKey = folderScope(request.organization.id, request.user.id, input.parentId, input.name); const duplicate = await prisma.folder.findUnique({ where: { scopeKey }, select: { id: true } }); if (duplicate) throw new HttpError(409, "A folder with that name already exists here"); - const driveFolderId = await createChildFolder(input.name, parent?.driveFolderId || request.user.driveFolderId); + const memberRoot = parent?.driveFolderId || await ensureMembershipRoot(request.membership.id); + if (!memberRoot) throw new HttpError(409, "Organization administrator must connect Google Drive first"); + const driveFolderId = await createChildFolder(request.organization.id, input.name, memberRoot); try { const folder = await prisma.folder.create({ - data: { name: input.name, parentId: input.parentId || null, driveFolderId, scopeKey, userId: request.user.id }, + data: { + name: input.name, + parentId: input.parentId || null, + driveFolderId, + scopeKey, + userId: request.user.id, + organizationId: request.organization.id, + }, }); response.status(201).json({ folder }); } catch (error) { - await deleteDriveItem(driveFolderId).catch(() => {}); + await deleteDriveItem(request.organization.id, driveFolderId).catch(() => {}); throw error; } })); router.patch("/:id", asyncHandler(async (request, response) => { - const folder = await ownedFolder(request.user.id, z.string().uuid().parse(request.params.id)); + const folder = await ownedFolder(request, z.string().uuid().parse(request.params.id)); const { name } = z.object({ name: folderName }).parse(request.body); - const scopeKey = folderScope(request.user.id, folder.parentId, name); + const scopeKey = folderScope(request.organization.id, request.user.id, folder.parentId, name); const duplicate = await prisma.folder.findFirst({ where: { scopeKey, NOT: { id: folder.id } }, select: { id: true } }); if (duplicate) throw new HttpError(409, "A folder with that name already exists here"); - await renameDriveItem(folder.driveFolderId, name); + await renameDriveItem(request.organization.id, folder.driveFolderId, name); const updated = await prisma.folder.update({ where: { id: folder.id }, data: { name, scopeKey } }); response.json({ folder: updated }); })); router.delete("/:id", asyncHandler(async (request, response) => { - const folder = await ownedFolder(request.user.id, z.string().uuid().parse(request.params.id)); + const folder = await ownedFolder(request, z.string().uuid().parse(request.params.id)); const counts = await prisma.folder.findUnique({ where: { id: folder.id }, select: { _count: { select: { children: true, files: true } } }, }); if (counts._count.children || counts._count.files) throw new HttpError(409, "Folder must be empty before deletion"); - await deleteDriveItem(folder.driveFolderId); + await deleteDriveItem(request.organization.id, folder.driveFolderId); await prisma.folder.delete({ where: { id: folder.id } }); response.status(204).end(); })); diff --git a/src/routes/organizationAdmin.js b/src/routes/organizationAdmin.js new file mode 100644 index 0000000..a154696 --- /dev/null +++ b/src/routes/organizationAdmin.js @@ -0,0 +1,98 @@ +import { Router } from "express"; +import { z } from "zod"; +import { prisma } from "../db.js"; +import { ensureMembershipRoot } from "../services/googleDrive.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { HttpError } from "../utils/httpError.js"; + +const router = Router(); + +function adminMembership(membership, counts = {}) { + return { + id: membership.id, + role: membership.role, + status: membership.status, + rejectionReason: membership.rejectionReason, + reviewedAt: membership.reviewedAt, + createdAt: membership.createdAt, + user: { + id: membership.user.id, + username: membership.user.username, + name: membership.user.name, + phone: membership.user.phone, + email: membership.user.email, + gender: membership.user.gender, + age: membership.user.age, + isSuspended: membership.user.isSuspended, + }, + counts, + }; +} + +router.get("/stats", asyncHandler(async (request, response) => { + const organizationId = request.organization.id; + const [members, approved, pending, rejected, files, folders, storage] = await Promise.all([ + prisma.membership.count({ where: { organizationId } }), + prisma.membership.count({ where: { organizationId, status: "APPROVED" } }), + prisma.membership.count({ where: { organizationId, status: "PENDING" } }), + prisma.membership.count({ where: { organizationId, status: "REJECTED" } }), + prisma.fileAsset.count({ where: { organizationId } }), + prisma.folder.count({ where: { organizationId } }), + prisma.fileAsset.aggregate({ where: { organizationId }, _sum: { sizeBytes: true } }), + ]); + response.json({ stats: { members, approved, pending, rejected, files, folders, storageBytes: Number(storage._sum.sizeBytes || 0n) } }); +})); + +router.get("/memberships", asyncHandler(async (request, response) => { + const query = z.object({ + search: z.string().trim().max(100).default(""), + status: z.enum(["ALL", "PENDING", "APPROVED", "REJECTED", "SUSPENDED"]).default("ALL"), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), + }).parse(request.query); + const where = { + organizationId: request.organization.id, + ...(query.status === "ALL" ? {} : { status: query.status }), + ...(query.search ? { user: { OR: ["name", "username", "email", "phone"].map((field) => ({ [field]: { contains: query.search, mode: "insensitive" } })) } } : {}), + }; + const [memberships, total] = await Promise.all([ + prisma.membership.findMany({ where, include: { user: true }, orderBy: { createdAt: "desc" }, skip: (query.page - 1) * query.limit, take: query.limit }), + prisma.membership.count({ where }), + ]); + const rows = await Promise.all(memberships.map(async (membership) => { + const [folders, files] = await Promise.all([ + prisma.folder.count({ where: { organizationId: request.organization.id, userId: membership.userId } }), + prisma.fileAsset.count({ where: { organizationId: request.organization.id, userId: membership.userId } }), + ]); + return adminMembership(membership, { folders, files }); + })); + response.json({ memberships: rows, pagination: { page: query.page, limit: query.limit, total, pages: Math.max(1, Math.ceil(total / query.limit)) } }); +})); + +router.patch("/memberships/:id", asyncHandler(async (request, response) => { + const input = z.object({ + status: z.enum(["APPROVED", "REJECTED", "SUSPENDED"]).optional(), + role: z.enum(["MEMBER", "ADMIN"]).optional(), + rejectionReason: z.string().trim().max(500).nullable().optional(), + }).refine((value) => Object.keys(value).length > 0, "At least one change is required").parse(request.body); + const membership = await prisma.membership.findFirst({ + where: { id: request.params.id, organizationId: request.organization.id }, + include: { user: true }, + }); + if (!membership) throw new HttpError(404, "Membership not found"); + if (membership.role === "OWNER") throw new HttpError(403, "The organization owner cannot be modified here"); + if (input.status === "REJECTED" && !input.rejectionReason) throw new HttpError(400, "A rejection reason is required"); + const updated = await prisma.membership.update({ + where: { id: membership.id }, + data: { + ...input, + ...(input.status ? { reviewedAt: new Date() } : {}), + ...(input.status === "APPROVED" ? { rejectionReason: null } : {}), + }, + include: { user: true }, + }); + if (updated.status === "APPROVED" && !updated.driveFolderId) await ensureMembershipRoot(updated.id); + response.json({ membership: adminMembership(updated) }); +})); + +export default router; diff --git a/src/routes/organizations.js b/src/routes/organizations.js new file mode 100644 index 0000000..6db49fe --- /dev/null +++ b/src/routes/organizations.js @@ -0,0 +1,144 @@ +import crypto from "node:crypto"; +import { Router } from "express"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import { z } from "zod"; +import { config } from "../config.js"; +import { prisma } from "../db.js"; +import { requireAuth } from "../middleware/auth.js"; +import { requireOrganization, requireOrganizationAdmin } from "../middleware/organization.js"; +import { authorizationInput, connectOrganizationDrive, googleAuthorizationUrl } from "../services/googleDrive.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { HttpError } from "../utils/httpError.js"; +import { publicUser } from "../utils/serializers.js"; + +const router = Router(); +const usernameRule = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9._-]+$/); +const ownerSchema = z.object({ + organizationName: z.string().trim().min(2).max(120), + name: z.string().trim().min(2).max(80), + username: usernameRule, + email: z.string().trim().toLowerCase().email().max(160), + phone: z.string().trim().min(7).max(20), + password: z.string().min(8).max(128), + gender: z.enum(["Male", "Female", "Non-binary", "Prefer not to say"]).default("Prefer not to say"), + age: z.coerce.number().int().min(13).max(120).default(18), +}); + +function issueToken(userId) { + return jwt.sign({}, config.JWT_SECRET, { subject: userId, expiresIn: config.JWT_EXPIRES_IN }); +} + +async function uniqueCode() { + for (let attempt = 0; attempt < 10; attempt++) { + const code = crypto.randomBytes(5).toString("base64url").replace(/[-_]/g, "").toUpperCase().slice(0, 8); + if (code.length >= 6 && !(await prisma.organization.findUnique({ where: { code }, select: { id: true } }))) return code; + } + throw new Error("Could not generate organization code"); +} + +router.get("/lookup/:code", asyncHandler(async (request, response) => { + const code = z.string().trim().toUpperCase().min(4).max(20).parse(request.params.code); + const organization = await prisma.organization.findUnique({ where: { code }, select: { id: true, code: true, name: true, status: true } }); + if (!organization || organization.status !== "ACTIVE") throw new HttpError(404, "Organization not found"); + response.json({ organization }); +})); + +router.post("/register", asyncHandler(async (request, response) => { + const input = ownerSchema.parse(request.body); + const duplicate = await prisma.user.findFirst({ + where: { OR: [{ username: input.username }, { email: input.email }, { phone: input.phone }] }, + select: { id: true }, + }); + if (duplicate) throw new HttpError(409, "Username, email, or phone is already registered"); + const code = await uniqueCode(); + const user = await prisma.user.create({ + data: { + username: input.username, + passwordHash: await bcrypt.hash(input.password, 12), + name: input.name, + email: input.email, + phone: input.phone, + gender: input.gender, + age: input.age, + memberships: { + create: { + role: "OWNER", + status: "APPROVED", + reviewedAt: new Date(), + organization: { create: { name: input.organizationName, code, status: "ACTIVE" } }, + }, + }, + }, + include: { memberships: { include: { organization: true } } }, + }); + response.status(201).json({ token: issueToken(user.id), user: publicUser(user), organization: user.memberships[0].organization }); +})); + +router.use(requireAuth, requireOrganization); + +router.get("/current", (request, response) => { + response.json({ organization: request.organization, membership: request.membership }); +}); + +router.get("/drive", requireOrganizationAdmin, asyncHandler(async (request, response) => { + const connection = await prisma.googleDriveConnection.findUnique({ where: { organizationId: request.organization.id } }); + response.json({ connection: connection ? { + status: connection.status, + storageType: connection.storageType, + sharedDriveId: connection.sharedDriveId, + rootFolderId: connection.rootFolderId, + connectedAt: connection.connectedAt, + } : null }); +})); + +router.post("/drive/authorization-url", requireOrganizationAdmin, (request, response) => { + const state = jwt.sign( + { purpose: "google-drive-connect", organizationId: request.organization.id, userId: request.user.id }, + config.JWT_SECRET, + { expiresIn: "10m" }, + ); + response.json({ authorizationUrl: googleAuthorizationUrl(state), state }); +}); + +router.post("/drive/exchange", requireOrganizationAdmin, asyncHandler(async (request, response) => { + const input = z.object({ + redirectUrlOrCode: z.string().trim().min(1), + state: z.string().optional(), + storageType: z.enum(["MY_DRIVE", "SHARED_DRIVE"]), + sharedDriveId: z.string().trim().min(3).optional(), + }).parse(request.body); + const parsed = authorizationInput(input.redirectUrlOrCode); + const state = parsed.state || input.state; + if (!parsed.code || !state) throw new HttpError(400, "The redirect URL is missing its authorization code or state"); + let payload; + try { payload = jwt.verify(state, config.JWT_SECRET); } catch { throw new HttpError(400, "Authorization session expired. Start Google connection again."); } + if (payload.purpose !== "google-drive-connect" || payload.organizationId !== request.organization.id || payload.userId !== request.user.id) { + throw new HttpError(403, "Google authorization session does not match this organization"); + } + const connection = await connectOrganizationDrive({ + organization: request.organization, + code: parsed.code, + storageType: input.storageType, + sharedDriveId: input.sharedDriveId, + }); + response.json({ connection: { + status: connection.status, + storageType: connection.storageType, + sharedDriveId: connection.sharedDriveId, + rootFolderId: connection.rootFolderId, + connectedAt: connection.connectedAt, + } }); +})); + +router.delete("/drive", requireOrganizationAdmin, asyncHandler(async (request, response) => { + const [folders, files] = await Promise.all([ + prisma.folder.count({ where: { organizationId: request.organization.id } }), + prisma.fileAsset.count({ where: { organizationId: request.organization.id } }), + ]); + if (folders || files) throw new HttpError(409, "Google Drive cannot be disconnected while organization content exists"); + await prisma.googleDriveConnection.deleteMany({ where: { organizationId: request.organization.id } }); + response.status(204).end(); +})); + +export default router; diff --git a/src/routes/retention.js b/src/routes/retention.js index 234ea47..4f121b9 100644 --- a/src/routes/retention.js +++ b/src/routes/retention.js @@ -13,6 +13,13 @@ router.delete("/account", asyncHandler(async (request, response) => { throw new HttpError(401, "Password confirmation is incorrect"); } + const ownedOrganizations = await prisma.membership.count({ + where: { userId: request.user.id, role: "OWNER" }, + }); + if (ownedOrganizations) { + throw new HttpError(409, "Transfer or close organizations you own before deleting your account"); + } + // Relations cascade in PostgreSQL. Google Drive is deliberately untouched. await prisma.user.delete({ where: { id: request.user.id } }); response.status(204).end(); diff --git a/src/services/googleDrive.js b/src/services/googleDrive.js index 14985da..33c5178 100644 --- a/src/services/googleDrive.js +++ b/src/services/googleDrive.js @@ -1,110 +1,210 @@ import fs from "node:fs"; import { google } from "googleapis"; import { config } from "../config.js"; +import { prisma } from "../db.js"; +import { HttpError } from "../utils/httpError.js"; +import { decryptToken, encryptToken } from "../utils/tokenVault.js"; -let driveClient; -let applicationRootId; +const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file"; -function loadJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); +function loadOAuthDefinition() { + const credentials = JSON.parse(fs.readFileSync(config.credentialsPath, "utf8")); + const definition = credentials.installed || credentials.web; + if (!definition) throw new Error("Invalid Google OAuth credentials file"); + return definition; } -export function getDrive() { - if (driveClient) return driveClient; - const credentials = loadJson(config.credentialsPath); - const client = credentials.installed || credentials.web; - if (!client) throw new Error("Invalid Google OAuth credentials file"); - const oauth = new google.auth.OAuth2( - client.client_id, - client.client_secret, - client.redirect_uris?.[0], +function oauthClient() { + const definition = loadOAuthDefinition(); + return new google.auth.OAuth2( + definition.client_id, + definition.client_secret, + definition.redirect_uris?.[0], ); - oauth.setCredentials(loadJson(config.tokenPath)); - oauth.on("tokens", (tokens) => { - const existing = loadJson(config.tokenPath); - fs.writeFileSync(config.tokenPath, JSON.stringify({ ...existing, ...tokens }, null, 2)); +} + +export function googleAuthorizationUrl(state) { + return oauthClient().generateAuthUrl({ + access_type: "offline", + prompt: "consent", + include_granted_scopes: true, + scope: [DRIVE_SCOPE], + state, }); - driveClient = google.drive({ version: "v3", auth: oauth }); - return driveClient; } -function escapeQuery(value) { - return String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'"); -} - -export async function findOrCreateFolder(name, parentId) { - const drive = getDrive(); - const query = [ - "mimeType='application/vnd.google-apps.folder'", - `name='${escapeQuery(name)}'`, - "trashed=false", - parentId ? `'${parentId}' in parents` : null, - ].filter(Boolean).join(" and "); - const existing = await drive.files.list({ q: query, fields: "files(id,name)", pageSize: 1 }); - if (existing.data.files?.length) return existing.data.files[0].id; - const created = await drive.files.create({ - requestBody: { - name, - mimeType: "application/vnd.google-apps.folder", - parents: parentId ? [parentId] : undefined, - }, - fields: "id", - }); - return created.data.id; -} - -export async function getApplicationRootId() { - if (!applicationRootId) { - applicationRootId = await findOrCreateFolder(config.GOOGLE_DRIVE_ROOT_FOLDER); +export function authorizationInput(value) { + const input = String(value || "").trim(); + if (!input) throw new HttpError(400, "Paste the Google localhost redirect URL"); + try { + const url = new URL(input); + const error = url.searchParams.get("error"); + if (error) throw new HttpError(400, `Google authorization failed: ${error}`); + return { code: url.searchParams.get("code"), state: url.searchParams.get("state") }; + } catch (error) { + if (error instanceof HttpError) throw error; + return { code: input, state: null }; } - return applicationRootId; } -async function createFolder(name, parentId) { - const created = await getDrive().files.create({ +async function driveForOrganization(organizationId) { + const connection = await prisma.googleDriveConnection.findUnique({ where: { organizationId } }); + if (!connection || connection.status !== "CONNECTED") { + throw new HttpError(409, "This organization has not connected Google Drive"); + } + const client = oauthClient(); + let stored = decryptToken(connection.tokenCiphertext); + client.setCredentials(stored); + client.on("tokens", async (tokens) => { + stored = { ...stored, ...tokens }; + await prisma.googleDriveConnection.update({ + where: { organizationId }, + data: { tokenCiphertext: encryptToken(stored), status: "CONNECTED" }, + }).catch(() => {}); + }); + return { drive: google.drive({ version: "v3", auth: client }), connection }; +} + +async function createFolderWithDrive(drive, name, parentId) { + const result = await drive.files.create({ requestBody: { name, mimeType: "application/vnd.google-apps.folder", parents: parentId ? [parentId] : undefined, }, fields: "id", + supportsAllDrives: true, }); - return created.data.id; + return result.data.id; } -export async function createUserRootFolder(name, username) { - const root = await getApplicationRootId(); - const safeName = String(name).trim().replace(/[\\/:*?"<>|]/g, "_"); - return createFolder(`${safeName} (${username})`, root); +export async function connectOrganizationDrive({ organization, code, storageType, sharedDriveId }) { + const client = oauthClient(); + const { tokens } = await client.getToken(code); + if (!tokens.refresh_token) { + throw new HttpError(400, "Google did not return a refresh token. Reconnect and approve access again."); + } + client.setCredentials(tokens); + const drive = google.drive({ version: "v3", auth: client }); + const parentId = storageType === "SHARED_DRIVE" ? sharedDriveId : null; + if (storageType === "SHARED_DRIVE" && !parentId) { + throw new HttpError(400, "Shared Drive ID is required"); + } + const existing = await prisma.googleDriveConnection.findUnique({ + where: { organizationId: organization.id }, + }); + const sameStorage = existing && + existing.storageType === storageType && + (storageType !== "SHARED_DRIVE" || existing.sharedDriveId === sharedDriveId); + let rootFolderId = sameStorage ? existing.rootFolderId : null; + if (rootFolderId) { + try { + await drive.files.get({ fileId: rootFolderId, fields: "id", supportsAllDrives: true }); + } catch { + throw new HttpError(409, "The connected Google account cannot access the existing organization root folder"); + } + } + if (existing && !sameStorage) { + const [folderCount, fileCount] = await Promise.all([ + prisma.folder.count({ where: { organizationId: organization.id } }), + prisma.fileAsset.count({ where: { organizationId: organization.id } }), + ]); + if (folderCount || fileCount) { + throw new HttpError(409, "Storage type cannot be changed after organization content has been created"); + } + } + const safeName = organization.name.trim().replace(/[\\/:*?"<>|]/g, "_"); + rootFolderId ||= await createFolderWithDrive(drive, `${safeName} - Metatron Drive`, parentId); + const connection = await prisma.googleDriveConnection.upsert({ + where: { organizationId: organization.id }, + create: { + organizationId: organization.id, + tokenCiphertext: encryptToken(tokens), + storageType, + sharedDriveId: storageType === "SHARED_DRIVE" ? sharedDriveId : null, + rootFolderId, + scope: tokens.scope || DRIVE_SCOPE, + status: "CONNECTED", + }, + update: { + tokenCiphertext: encryptToken(tokens), + storageType, + sharedDriveId: storageType === "SHARED_DRIVE" ? sharedDriveId : null, + rootFolderId, + scope: tokens.scope || DRIVE_SCOPE, + status: "CONNECTED", + connectedAt: new Date(), + }, + }); + if (!sameStorage) { + await prisma.membership.updateMany({ + where: { organizationId: organization.id }, + data: { driveFolderId: null }, + }); + } + const approved = await prisma.membership.findMany({ + where: { organizationId: organization.id, status: "APPROVED", driveFolderId: null }, + select: { id: true }, + }); + for (const membership of approved) await ensureMembershipRoot(membership.id); + return connection; } -export async function createChildFolder(name, parentDriveFolderId) { - return createFolder(name, parentDriveFolderId); +export async function ensureMembershipRoot(membershipId) { + const membership = await prisma.membership.findUnique({ + where: { id: membershipId }, + include: { user: true, organization: { include: { driveConnection: true } } }, + }); + if (!membership || membership.status !== "APPROVED") throw new HttpError(403, "Membership is not approved"); + if (membership.driveFolderId) return membership.driveFolderId; + const connection = membership.organization.driveConnection; + if (!connection?.rootFolderId || connection.status !== "CONNECTED") return null; + const { drive } = await driveForOrganization(membership.organizationId); + const safeName = membership.user.name.trim().replace(/[\\/:*?"<>|]/g, "_"); + const driveFolderId = await createFolderWithDrive( + drive, + `${safeName} (${membership.user.username})`, + connection.rootFolderId, + ); + await prisma.membership.update({ where: { id: membership.id }, data: { driveFolderId } }); + return driveFolderId; } -export async function uploadFile({ filePath, originalName, mimeType, parentDriveFolderId }) { - const drive = getDrive(); +export async function createChildFolder(organizationId, name, parentDriveFolderId) { + const { drive } = await driveForOrganization(organizationId); + return createFolderWithDrive(drive, name, parentDriveFolderId); +} + +export async function uploadFile({ organizationId, filePath, originalName, mimeType, parentDriveFolderId }) { + const { drive } = await driveForOrganization(organizationId); const result = await drive.files.create({ requestBody: { name: originalName, parents: [parentDriveFolderId] }, media: { mimeType, body: fs.createReadStream(filePath) }, fields: "id,name,mimeType,size,createdTime", + supportsAllDrives: true, }); return result.data; } -export async function streamFile(driveFileId, response) { - const drive = getDrive(); +export async function streamFile(organizationId, driveFileId, response) { + const { drive } = await driveForOrganization(organizationId); const result = await drive.files.get( - { fileId: driveFileId, alt: "media" }, + { fileId: driveFileId, alt: "media", supportsAllDrives: true }, { responseType: "stream" }, ); result.data.pipe(response); } -export async function renameDriveItem(driveFileId, name) { - await getDrive().files.update({ fileId: driveFileId, requestBody: { name } }); +export async function renameDriveItem(organizationId, driveFileId, name) { + const { drive } = await driveForOrganization(organizationId); + await drive.files.update({ + fileId: driveFileId, + requestBody: { name }, + supportsAllDrives: true, + }); } -export async function deleteDriveItem(driveFileId) { - await getDrive().files.delete({ fileId: driveFileId }); +export async function deleteDriveItem(organizationId, driveFileId) { + const { drive } = await driveForOrganization(organizationId); + await drive.files.delete({ fileId: driveFileId, supportsAllDrives: true }); } diff --git a/src/utils/serializers.js b/src/utils/serializers.js index 43ff74a..77cc7a8 100644 --- a/src/utils/serializers.js +++ b/src/utils/serializers.js @@ -2,6 +2,21 @@ export function serializeFile(file) { return { ...file, sizeBytes: Number(file.sizeBytes) }; } +export function publicMembership(membership) { + return { + id: membership.id, + role: membership.role, + status: membership.status, + rejectionReason: membership.rejectionReason, + organization: membership.organization ? { + id: membership.organization.id, + code: membership.organization.code, + name: membership.organization.name, + status: membership.organization.status, + } : undefined, + }; +} + export function publicUser(user) { return { id: user.id, @@ -12,5 +27,6 @@ export function publicUser(user) { gender: user.gender, age: user.age, createdAt: user.createdAt, + memberships: user.memberships?.map(publicMembership), }; } diff --git a/src/utils/tokenVault.js b/src/utils/tokenVault.js new file mode 100644 index 0000000..1cf3888 --- /dev/null +++ b/src/utils/tokenVault.js @@ -0,0 +1,23 @@ +import crypto from "node:crypto"; +import { config } from "../config.js"; + +const key = crypto.createHash("sha256").update(config.JWT_SECRET).digest(); + +export function encryptToken(value) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]); + return [iv, cipher.getAuthTag(), encrypted].map((part) => part.toString("base64url")).join("."); +} + +export function decryptToken(value) { + const [ivValue, tagValue, encryptedValue] = String(value).split("."); + if (!ivValue || !tagValue || !encryptedValue) throw new Error("Invalid encrypted Google token"); + const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(ivValue, "base64url")); + decipher.setAuthTag(Buffer.from(tagValue, "base64url")); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(encryptedValue, "base64url")), + decipher.final(), + ]); + return JSON.parse(decrypted.toString("utf8")); +}