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.
This commit is contained in:
parent
22863fc861
commit
6b105c07c4
@ -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;
|
||||||
@ -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 {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
@ -12,53 +7,105 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
name String
|
name String
|
||||||
phone String @unique
|
phone String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
gender String
|
gender String
|
||||||
age Int
|
age Int
|
||||||
driveFolderId String @unique
|
isSuspended Boolean @default(false)
|
||||||
isSuspended Boolean @default(false)
|
createdAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
updatedAt DateTime @updatedAt
|
||||||
updatedAt DateTime @updatedAt
|
memberships Membership[]
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
files FileAsset[]
|
files FileAsset[]
|
||||||
|
|
||||||
@@index([isSuspended, createdAt])
|
@@index([isSuspended, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Folder {
|
model Organization {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
code String @unique
|
||||||
driveFolderId String @unique
|
name String
|
||||||
scopeKey String? @unique
|
status String @default("ACTIVE")
|
||||||
userId String
|
createdAt DateTime @default(now())
|
||||||
parentId String?
|
updatedAt DateTime @updatedAt
|
||||||
createdAt DateTime @default(now())
|
memberships Membership[]
|
||||||
updatedAt DateTime @updatedAt
|
driveConnection GoogleDriveConnection?
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
folders Folder[]
|
||||||
parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
|
files FileAsset[]
|
||||||
children Folder[] @relation("FolderTree")
|
|
||||||
files FileAsset[]
|
|
||||||
|
|
||||||
@@unique([userId, parentId, name])
|
@@index([status, createdAt])
|
||||||
@@index([userId, parentId])
|
}
|
||||||
|
|
||||||
|
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 {
|
model FileAsset {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
originalName String
|
originalName String
|
||||||
driveFileId String @unique
|
driveFileId String @unique
|
||||||
mimeType String
|
mimeType String
|
||||||
sizeBytes BigInt
|
sizeBytes BigInt
|
||||||
userId String
|
userId String
|
||||||
folderId String?
|
organizationId String
|
||||||
createdAt DateTime @default(now())
|
folderId String?
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
createdAt DateTime @default(now())
|
||||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
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])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,12 +5,15 @@ import morgan from "morgan";
|
|||||||
import { rateLimit } from "express-rate-limit";
|
import { rateLimit } from "express-rate-limit";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { requireAuth } from "./middleware/auth.js";
|
import { requireAuth } from "./middleware/auth.js";
|
||||||
|
import { requireOrganization, requireOrganizationAdmin } from "./middleware/organization.js";
|
||||||
import { errorHandler, notFound } from "./middleware/errorHandler.js";
|
import { errorHandler, notFound } from "./middleware/errorHandler.js";
|
||||||
import authRoutes from "./routes/auth.js";
|
import authRoutes from "./routes/auth.js";
|
||||||
import folderRoutes from "./routes/folders.js";
|
import folderRoutes from "./routes/folders.js";
|
||||||
import fileRoutes from "./routes/files.js";
|
import fileRoutes from "./routes/files.js";
|
||||||
import retentionRoutes from "./routes/retention.js";
|
import retentionRoutes from "./routes/retention.js";
|
||||||
import adminRoutes from "./routes/admin.js";
|
import adminRoutes from "./routes/admin.js";
|
||||||
|
import organizationRoutes from "./routes/organizations.js";
|
||||||
|
import organizationAdminRoutes from "./routes/organizationAdmin.js";
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
app.disable("x-powered-by");
|
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 });
|
const adminLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 50, standardHeaders: "draft-8", legacyHeaders: false });
|
||||||
app.use("/api/auth", authLimiter, authRoutes);
|
app.use("/api/auth", authLimiter, authRoutes);
|
||||||
app.use("/api/admin", adminLimiter, adminRoutes);
|
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/upload", uploadLimiter);
|
||||||
app.use("/api/files", requireAuth, fileRoutes);
|
app.use("/api/files", requireAuth, requireOrganization, fileRoutes);
|
||||||
app.use("/api/retention", requireAuth, retentionRoutes);
|
app.use("/api/retention", requireAuth, retentionRoutes);
|
||||||
|
|
||||||
app.use(notFound);
|
app.use(notFound);
|
||||||
|
|||||||
31
src/middleware/organization.js
Normal file
31
src/middleware/organization.js
Normal file
@ -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();
|
||||||
|
}
|
||||||
@ -4,7 +4,6 @@ import jwt from "jsonwebtoken";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { createUserRootFolder, deleteDriveItem } from "../services/googleDrive.js";
|
|
||||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||||
import { HttpError } from "../utils/httpError.js";
|
import { HttpError } from "../utils/httpError.js";
|
||||||
import { publicUser } from "../utils/serializers.js";
|
import { publicUser } from "../utils/serializers.js";
|
||||||
@ -12,7 +11,7 @@ import { requireAuth } from "../middleware/auth.js";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const usernameRule = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9._-]+$/);
|
const usernameRule = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9._-]+$/);
|
||||||
const registerSchema = z.object({
|
const profileSchema = z.object({
|
||||||
username: usernameRule,
|
username: usernameRule,
|
||||||
password: z.string().min(8).max(128),
|
password: z.string().min(8).max(128),
|
||||||
name: z.string().trim().min(2).max(80),
|
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"]),
|
gender: z.enum(["Male", "Female", "Non-binary", "Prefer not to say"]),
|
||||||
age: z.coerce.number().int().min(13).max(120),
|
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) });
|
const loginSchema = z.object({ username: usernameRule, password: z.string().min(1).max(128) });
|
||||||
|
|
||||||
function issueToken(userId) {
|
function issueToken(userId) {
|
||||||
return jwt.sign({}, config.JWT_SECRET, { subject: userId, expiresIn: config.JWT_EXPIRES_IN });
|
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) => {
|
router.post("/register", asyncHandler(async (request, response) => {
|
||||||
const input = registerSchema.parse(request.body);
|
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({
|
const duplicate = await prisma.user.findFirst({
|
||||||
where: { OR: [{ username: input.username }, { email: input.email }, { phone: input.phone }] },
|
where: { OR: [{ username: input.username }, { email: input.email }, { phone: input.phone }] },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (duplicate) throw new HttpError(409, "Username, email, or phone is already registered");
|
if (duplicate) throw new HttpError(409, "An account already exists. Sign in and join this organization instead.");
|
||||||
|
const { password, organizationCode, ...profile } = input;
|
||||||
let driveFolderId;
|
const user = await prisma.user.create({
|
||||||
try {
|
data: {
|
||||||
driveFolderId = await createUserRootFolder(input.name, input.username);
|
...profile,
|
||||||
const { password, ...profile } = input;
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
const user = await prisma.user.create({
|
memberships: { create: { organizationId: organization.id, status: "PENDING", role: "MEMBER" } },
|
||||||
data: { ...profile, passwordHash: await bcrypt.hash(password, 12), driveFolderId },
|
},
|
||||||
});
|
include: membershipInclude(),
|
||||||
response.status(201).json({ token: issueToken(user.id), user: publicUser(user) });
|
});
|
||||||
} catch (error) {
|
response.status(202).json({
|
||||||
if (driveFolderId) await deleteDriveItem(driveFolderId).catch(() => {});
|
message: "Application submitted. You can sign in after an organization administrator approves it.",
|
||||||
throw error;
|
user: publicUser(user),
|
||||||
}
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.post("/login", asyncHandler(async (request, response) => {
|
router.post("/login", asyncHandler(async (request, response) => {
|
||||||
const input = loginSchema.parse(request.body);
|
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))) {
|
if (!user || !(await bcrypt.compare(input.password, user.passwordHash))) {
|
||||||
throw new HttpError(401, "Invalid username or password");
|
throw new HttpError(401, "Invalid username or password");
|
||||||
}
|
}
|
||||||
if (user.isSuspended) throw new HttpError(403, "Account is suspended. Contact the administrator.");
|
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) });
|
response.json({ token: issueToken(user.id), user: publicUser(user) });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.get("/me", requireAuth, (request, response) => {
|
router.get("/me", requireAuth, asyncHandler(async (request, response) => {
|
||||||
response.json({ user: publicUser(request.user) });
|
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;
|
export default router;
|
||||||
|
|||||||
@ -6,12 +6,14 @@ import { uploadSingle } from "../middleware/upload.js";
|
|||||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||||
import { HttpError } from "../utils/httpError.js";
|
import { HttpError } from "../utils/httpError.js";
|
||||||
import { serializeFile } from "../utils/serializers.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();
|
const router = Router();
|
||||||
|
|
||||||
async function ownedFile(userId, id) {
|
async function ownedFile(request, id) {
|
||||||
const file = await prisma.fileAsset.findFirst({ where: { id, userId } });
|
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");
|
if (!file) throw new HttpError(404, "File not found");
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
@ -19,11 +21,14 @@ async function ownedFile(userId, id) {
|
|||||||
router.get("/", asyncHandler(async (request, response) => {
|
router.get("/", asyncHandler(async (request, response) => {
|
||||||
const folderId = request.query.folderId ? z.string().uuid().parse(request.query.folderId) : null;
|
const folderId = request.query.folderId ? z.string().uuid().parse(request.query.folderId) : null;
|
||||||
if (folderId) {
|
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");
|
if (!folder) throw new HttpError(404, "Folder not found");
|
||||||
}
|
}
|
||||||
const files = await prisma.fileAsset.findMany({
|
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" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
response.json({ files: files.map(serializeFile) });
|
response.json({ files: files.map(serializeFile) });
|
||||||
@ -34,13 +39,17 @@ router.post("/upload", uploadSingle, asyncHandler(async (request, response) => {
|
|||||||
let uploaded;
|
let uploaded;
|
||||||
try {
|
try {
|
||||||
const folderId = request.body.folderId ? z.string().uuid().parse(request.body.folderId) : null;
|
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) {
|
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");
|
if (!folder) throw new HttpError(404, "Folder not found");
|
||||||
parentDriveFolderId = folder.driveFolderId;
|
parentDriveFolderId = folder.driveFolderId;
|
||||||
}
|
}
|
||||||
uploaded = await uploadFile({
|
uploaded = await uploadFile({
|
||||||
|
organizationId: request.organization.id,
|
||||||
filePath: request.file.path,
|
filePath: request.file.path,
|
||||||
originalName: request.file.originalname,
|
originalName: request.file.originalname,
|
||||||
mimeType: request.file.mimetype || "application/octet-stream",
|
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",
|
mimeType: request.file.mimetype || uploaded.mimeType || "application/octet-stream",
|
||||||
sizeBytes: BigInt(request.file.size),
|
sizeBytes: BigInt(request.file.size),
|
||||||
userId: request.user.id,
|
userId: request.user.id,
|
||||||
|
organizationId: request.organization.id,
|
||||||
folderId,
|
folderId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
response.status(201).json({ file: serializeFile(file) });
|
response.status(201).json({ file: serializeFile(file) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (uploaded?.id) await deleteDriveItem(uploaded.id).catch(() => {});
|
if (uploaded?.id) await deleteDriveItem(request.organization.id, uploaded.id).catch(() => {});
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await fs.unlink(request.file.path).catch(() => {});
|
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) => {
|
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-Type", file.mimeType);
|
||||||
response.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.originalName)}`);
|
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) => {
|
router.delete("/:id", 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));
|
||||||
await deleteDriveItem(file.driveFileId);
|
await deleteDriveItem(request.organization.id, file.driveFileId);
|
||||||
await prisma.fileAsset.delete({ where: { id: file.id } });
|
await prisma.fileAsset.delete({ where: { id: file.id } });
|
||||||
response.status(204).end();
|
response.status(204).end();
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -3,27 +3,29 @@ import { z } from "zod";
|
|||||||
import { prisma } from "../db.js";
|
import { prisma } from "../db.js";
|
||||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||||
import { HttpError } from "../utils/httpError.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 router = Router();
|
||||||
const folderName = z.string().trim().min(1).max(120).refine((name) => !/[\\/:*?"<>|]/.test(name), "Folder name contains invalid characters");
|
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();
|
const optionalParent = z.string().uuid().nullable().optional();
|
||||||
|
|
||||||
function folderScope(userId, parentId, name) {
|
function folderScope(organizationId, userId, parentId, name) {
|
||||||
return `${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`;
|
return `${organizationId}:${userId}:${parentId || "root"}:${name.trim().toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ownedFolder(userId, id) {
|
async function ownedFolder(request, id) {
|
||||||
const folder = await prisma.folder.findFirst({ where: { id, userId } });
|
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");
|
if (!folder) throw new HttpError(404, "Folder not found");
|
||||||
return folder;
|
return folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get("/", asyncHandler(async (request, response) => {
|
router.get("/", asyncHandler(async (request, response) => {
|
||||||
const parentId = request.query.parentId ? z.string().uuid().parse(request.query.parentId) : null;
|
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({
|
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" },
|
orderBy: { name: "asc" },
|
||||||
include: { _count: { select: { children: true, files: true } } },
|
include: { _count: { select: { children: true, files: true } } },
|
||||||
});
|
});
|
||||||
@ -32,41 +34,50 @@ router.get("/", asyncHandler(async (request, response) => {
|
|||||||
|
|
||||||
router.post("/", asyncHandler(async (request, response) => {
|
router.post("/", asyncHandler(async (request, response) => {
|
||||||
const input = z.object({ name: folderName, parentId: optionalParent }).parse(request.body);
|
const input = z.object({ name: folderName, parentId: optionalParent }).parse(request.body);
|
||||||
const parent = input.parentId ? await ownedFolder(request.user.id, input.parentId) : null;
|
const parent = input.parentId ? await ownedFolder(request, input.parentId) : null;
|
||||||
const scopeKey = folderScope(request.user.id, input.parentId, input.name);
|
const scopeKey = folderScope(request.organization.id, request.user.id, input.parentId, input.name);
|
||||||
const duplicate = await prisma.folder.findUnique({ where: { scopeKey }, select: { id: true } });
|
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");
|
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 {
|
try {
|
||||||
const folder = await prisma.folder.create({
|
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 });
|
response.status(201).json({ folder });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await deleteDriveItem(driveFolderId).catch(() => {});
|
await deleteDriveItem(request.organization.id, driveFolderId).catch(() => {});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.patch("/:id", asyncHandler(async (request, response) => {
|
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 { 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 } });
|
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");
|
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 } });
|
const updated = await prisma.folder.update({ where: { id: folder.id }, data: { name, scopeKey } });
|
||||||
response.json({ folder: updated });
|
response.json({ folder: updated });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.delete("/:id", asyncHandler(async (request, response) => {
|
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({
|
const counts = await prisma.folder.findUnique({
|
||||||
where: { id: folder.id },
|
where: { id: folder.id },
|
||||||
select: { _count: { select: { children: true, files: true } } },
|
select: { _count: { select: { children: true, files: true } } },
|
||||||
});
|
});
|
||||||
if (counts._count.children || counts._count.files) throw new HttpError(409, "Folder must be empty before deletion");
|
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 } });
|
await prisma.folder.delete({ where: { id: folder.id } });
|
||||||
response.status(204).end();
|
response.status(204).end();
|
||||||
}));
|
}));
|
||||||
|
|||||||
98
src/routes/organizationAdmin.js
Normal file
98
src/routes/organizationAdmin.js
Normal file
@ -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;
|
||||||
144
src/routes/organizations.js
Normal file
144
src/routes/organizations.js
Normal file
@ -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;
|
||||||
@ -13,6 +13,13 @@ router.delete("/account", asyncHandler(async (request, response) => {
|
|||||||
throw new HttpError(401, "Password confirmation is incorrect");
|
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.
|
// Relations cascade in PostgreSQL. Google Drive is deliberately untouched.
|
||||||
await prisma.user.delete({ where: { id: request.user.id } });
|
await prisma.user.delete({ where: { id: request.user.id } });
|
||||||
response.status(204).end();
|
response.status(204).end();
|
||||||
|
|||||||
@ -1,110 +1,210 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
import { config } from "../config.js";
|
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;
|
const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
||||||
let applicationRootId;
|
|
||||||
|
|
||||||
function loadJson(filePath) {
|
function loadOAuthDefinition() {
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
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() {
|
function oauthClient() {
|
||||||
if (driveClient) return driveClient;
|
const definition = loadOAuthDefinition();
|
||||||
const credentials = loadJson(config.credentialsPath);
|
return new google.auth.OAuth2(
|
||||||
const client = credentials.installed || credentials.web;
|
definition.client_id,
|
||||||
if (!client) throw new Error("Invalid Google OAuth credentials file");
|
definition.client_secret,
|
||||||
const oauth = new google.auth.OAuth2(
|
definition.redirect_uris?.[0],
|
||||||
client.client_id,
|
|
||||||
client.client_secret,
|
|
||||||
client.redirect_uris?.[0],
|
|
||||||
);
|
);
|
||||||
oauth.setCredentials(loadJson(config.tokenPath));
|
}
|
||||||
oauth.on("tokens", (tokens) => {
|
|
||||||
const existing = loadJson(config.tokenPath);
|
export function googleAuthorizationUrl(state) {
|
||||||
fs.writeFileSync(config.tokenPath, JSON.stringify({ ...existing, ...tokens }, null, 2));
|
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) {
|
export function authorizationInput(value) {
|
||||||
return String(value).replaceAll("\\", "\\\\").replaceAll("'", "\\'");
|
const input = String(value || "").trim();
|
||||||
}
|
if (!input) throw new HttpError(400, "Paste the Google localhost redirect URL");
|
||||||
|
try {
|
||||||
export async function findOrCreateFolder(name, parentId) {
|
const url = new URL(input);
|
||||||
const drive = getDrive();
|
const error = url.searchParams.get("error");
|
||||||
const query = [
|
if (error) throw new HttpError(400, `Google authorization failed: ${error}`);
|
||||||
"mimeType='application/vnd.google-apps.folder'",
|
return { code: url.searchParams.get("code"), state: url.searchParams.get("state") };
|
||||||
`name='${escapeQuery(name)}'`,
|
} catch (error) {
|
||||||
"trashed=false",
|
if (error instanceof HttpError) throw error;
|
||||||
parentId ? `'${parentId}' in parents` : null,
|
return { code: input, state: 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);
|
|
||||||
}
|
}
|
||||||
return applicationRootId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createFolder(name, parentId) {
|
async function driveForOrganization(organizationId) {
|
||||||
const created = await getDrive().files.create({
|
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: {
|
requestBody: {
|
||||||
name,
|
name,
|
||||||
mimeType: "application/vnd.google-apps.folder",
|
mimeType: "application/vnd.google-apps.folder",
|
||||||
parents: parentId ? [parentId] : undefined,
|
parents: parentId ? [parentId] : undefined,
|
||||||
},
|
},
|
||||||
fields: "id",
|
fields: "id",
|
||||||
|
supportsAllDrives: true,
|
||||||
});
|
});
|
||||||
return created.data.id;
|
return result.data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUserRootFolder(name, username) {
|
export async function connectOrganizationDrive({ organization, code, storageType, sharedDriveId }) {
|
||||||
const root = await getApplicationRootId();
|
const client = oauthClient();
|
||||||
const safeName = String(name).trim().replace(/[\\/:*?"<>|]/g, "_");
|
const { tokens } = await client.getToken(code);
|
||||||
return createFolder(`${safeName} (${username})`, root);
|
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) {
|
export async function ensureMembershipRoot(membershipId) {
|
||||||
return createFolder(name, parentDriveFolderId);
|
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 }) {
|
export async function createChildFolder(organizationId, name, parentDriveFolderId) {
|
||||||
const drive = getDrive();
|
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({
|
const result = await drive.files.create({
|
||||||
requestBody: { name: originalName, parents: [parentDriveFolderId] },
|
requestBody: { name: originalName, parents: [parentDriveFolderId] },
|
||||||
media: { mimeType, body: fs.createReadStream(filePath) },
|
media: { mimeType, body: fs.createReadStream(filePath) },
|
||||||
fields: "id,name,mimeType,size,createdTime",
|
fields: "id,name,mimeType,size,createdTime",
|
||||||
|
supportsAllDrives: true,
|
||||||
});
|
});
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function streamFile(driveFileId, response) {
|
export async function streamFile(organizationId, driveFileId, response) {
|
||||||
const drive = getDrive();
|
const { drive } = await driveForOrganization(organizationId);
|
||||||
const result = await drive.files.get(
|
const result = await drive.files.get(
|
||||||
{ fileId: driveFileId, alt: "media" },
|
{ fileId: driveFileId, alt: "media", supportsAllDrives: true },
|
||||||
{ responseType: "stream" },
|
{ responseType: "stream" },
|
||||||
);
|
);
|
||||||
result.data.pipe(response);
|
result.data.pipe(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renameDriveItem(driveFileId, name) {
|
export async function renameDriveItem(organizationId, driveFileId, name) {
|
||||||
await getDrive().files.update({ fileId: driveFileId, requestBody: { name } });
|
const { drive } = await driveForOrganization(organizationId);
|
||||||
|
await drive.files.update({
|
||||||
|
fileId: driveFileId,
|
||||||
|
requestBody: { name },
|
||||||
|
supportsAllDrives: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDriveItem(driveFileId) {
|
export async function deleteDriveItem(organizationId, driveFileId) {
|
||||||
await getDrive().files.delete({ fileId: driveFileId });
|
const { drive } = await driveForOrganization(organizationId);
|
||||||
|
await drive.files.delete({ fileId: driveFileId, supportsAllDrives: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,21 @@ export function serializeFile(file) {
|
|||||||
return { ...file, sizeBytes: Number(file.sizeBytes) };
|
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) {
|
export function publicUser(user) {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@ -12,5 +27,6 @@ export function publicUser(user) {
|
|||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
age: user.age,
|
age: user.age,
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
|
memberships: user.memberships?.map(publicMembership),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/utils/tokenVault.js
Normal file
23
src/utils/tokenVault.js
Normal file
@ -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"));
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user