feat: add family-based multi-tenant access control
Some checks failed
Basil CI/CD Pipeline / Code Linting (push) Successful in 3m18s
Basil CI/CD Pipeline / Web Tests (push) Successful in 3m31s
Basil CI/CD Pipeline / Security Scanning (push) Has been cancelled
Basil CI/CD Pipeline / API Tests (push) Failing after 3m56s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 3m11s
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Build All Packages (push) Has been cancelled
Basil CI/CD Pipeline / E2E Tests (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
Some checks failed
Basil CI/CD Pipeline / Code Linting (push) Successful in 3m18s
Basil CI/CD Pipeline / Web Tests (push) Successful in 3m31s
Basil CI/CD Pipeline / Security Scanning (push) Has been cancelled
Basil CI/CD Pipeline / API Tests (push) Failing after 3m56s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 3m11s
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Build All Packages (push) Has been cancelled
Basil CI/CD Pipeline / E2E Tests (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
Introduces Family as the tenant boundary so recipes and cookbooks can be scoped per household instead of every user seeing everything. Adds a centralized access filter, an invite/membership UI, a first-login prompt to create a family, and locks down the previously unauthenticated backup routes to admin only. - Family and FamilyMember models with OWNER/MEMBER roles; familyId on Recipe and Cookbook (ON DELETE SET NULL so deleting a family orphans content rather than destroying it). - access.service.ts composes a single WhereInput covering owner, family, PUBLIC visibility, and direct share; admins short-circuit to full access. - recipes/cookbooks routes now require auth, strip client-supplied userId/familyId on create, and gate mutations with canMutate checks. Auto-filter helpers scoped to the same family to prevent cross-tenant leakage via shared tag names. - families.routes.ts exposes list/create/get/rename/delete plus add/remove member, with last-owner protection on removal. - FamilyGate component blocks the authenticated UI with a modal if the user has zero memberships, prompting them to create their first family; Family page provides ongoing management. - backup.routes.ts now requires admin; it had no auth at all before. - Bumps version to 2026.04.008 and documents the monotonic PPP counter in CLAUDE.md. Migration SQL is generated locally but not tracked (per existing .gitignore); apply 20260416010000_add_family_tenant to prod during deploy. Run backfill-family-tenant.ts once post-migration to assign existing content to a default owner's family. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -29,11 +29,45 @@ model User {
|
||||
refreshTokens RefreshToken[]
|
||||
verificationTokens VerificationToken[]
|
||||
mealPlans MealPlan[]
|
||||
familyMemberships FamilyMember[]
|
||||
|
||||
@@index([email])
|
||||
@@index([provider, providerId])
|
||||
}
|
||||
|
||||
enum FamilyRole {
|
||||
OWNER
|
||||
MEMBER
|
||||
}
|
||||
|
||||
model Family {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members FamilyMember[]
|
||||
recipes Recipe[]
|
||||
cookbooks Cookbook[]
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
model FamilyMember {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
familyId String
|
||||
role FamilyRole @default(MEMBER)
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, familyId])
|
||||
@@index([userId])
|
||||
@@index([familyId])
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
@@ -91,12 +125,14 @@ model Recipe {
|
||||
cuisine String?
|
||||
categories String[] @default([]) // Changed from single category to array
|
||||
rating Float?
|
||||
userId String? // Recipe owner
|
||||
userId String? // Recipe owner (creator)
|
||||
familyId String? // Owning family (tenant scope)
|
||||
visibility Visibility @default(PRIVATE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull)
|
||||
sections RecipeSection[]
|
||||
ingredients Ingredient[]
|
||||
instructions Instruction[]
|
||||
@@ -109,6 +145,7 @@ model Recipe {
|
||||
@@index([title])
|
||||
@@index([cuisine])
|
||||
@@index([userId])
|
||||
@@index([familyId])
|
||||
@@index([visibility])
|
||||
}
|
||||
|
||||
@@ -236,7 +273,8 @@ model Cookbook {
|
||||
name String
|
||||
description String?
|
||||
coverImageUrl String?
|
||||
userId String? // Cookbook owner
|
||||
userId String? // Cookbook owner (creator)
|
||||
familyId String? // Owning family (tenant scope)
|
||||
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
|
||||
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
|
||||
autoFilterCookbookTags String[] @default([]) // Auto-add cookbooks matching these tags
|
||||
@@ -244,6 +282,7 @@ model Cookbook {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull)
|
||||
recipes CookbookRecipe[]
|
||||
tags CookbookTag[]
|
||||
includedCookbooks CookbookInclusion[] @relation("ParentCookbook")
|
||||
@@ -251,6 +290,7 @@ model Cookbook {
|
||||
|
||||
@@index([name])
|
||||
@@index([userId])
|
||||
@@index([familyId])
|
||||
}
|
||||
|
||||
model CookbookRecipe {
|
||||
|
||||
@@ -10,6 +10,7 @@ import tagsRoutes from './routes/tags.routes';
|
||||
import backupRoutes from './routes/backup.routes';
|
||||
import authRoutes from './routes/auth.routes';
|
||||
import mealPlansRoutes from './routes/meal-plans.routes';
|
||||
import familiesRoutes from './routes/families.routes';
|
||||
import './config/passport'; // Initialize passport strategies
|
||||
import { testEmailConfig } from './services/email.service';
|
||||
import { APP_VERSION } from './version';
|
||||
@@ -40,6 +41,7 @@ app.use('/api/cookbooks', cookbooksRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
app.use('/api/meal-plans', mealPlansRoutes);
|
||||
app.use('/api/families', familiesRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
@@ -2,10 +2,13 @@ import express, { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { createBackup, restoreBackup, listBackups, deleteBackup } from '../services/backup.service';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth.middleware';
|
||||
import multer from 'multer';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
// Configure multer for backup file uploads
|
||||
const upload = multer({
|
||||
dest: '/tmp/basil-restore/',
|
||||
|
||||
@@ -2,8 +2,16 @@ import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import prisma from '../config/database';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
import {
|
||||
getAccessContext,
|
||||
buildCookbookAccessFilter,
|
||||
canMutateCookbook,
|
||||
getPrimaryFamilyId,
|
||||
} from '../services/access.service';
|
||||
import { requireAuth } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
@@ -57,9 +65,11 @@ async function applyFiltersToExistingRecipes(cookbookId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Find matching recipes
|
||||
// Find matching recipes within the same family (tenant scope).
|
||||
if (!cookbook.familyId) return;
|
||||
const matchingRecipes = await prisma.recipe.findMany({
|
||||
where: {
|
||||
familyId: cookbook.familyId,
|
||||
OR: whereConditions
|
||||
},
|
||||
select: { id: true }
|
||||
@@ -107,11 +117,13 @@ async function applyFiltersToExistingCookbooks(cookbookId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching cookbooks (excluding self)
|
||||
// Find matching cookbooks within the same family (tenant scope).
|
||||
if (!cookbook.familyId) return;
|
||||
const matchingCookbooks = await prisma.cookbook.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: cookbookId } },
|
||||
{ familyId: cookbook.familyId },
|
||||
{
|
||||
tags: {
|
||||
some: {
|
||||
@@ -166,11 +178,14 @@ async function autoAddToParentCookbooks(cookbookId: string) {
|
||||
const cookbookTags = cookbook.tags.map((ct: any) => ct.tag.name);
|
||||
if (cookbookTags.length === 0) return;
|
||||
|
||||
// Find parent cookbooks with filters matching this cookbook's tags
|
||||
// Find parent cookbooks with filters matching this cookbook's tags,
|
||||
// scoped to the same family.
|
||||
if (!cookbook.familyId) return;
|
||||
const parentCookbooks = await prisma.cookbook.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: cookbookId } },
|
||||
{ familyId: cookbook.familyId },
|
||||
{ autoFilterCookbookTags: { hasSome: cookbookTags } }
|
||||
]
|
||||
}
|
||||
@@ -203,6 +218,8 @@ async function autoAddToParentCookbooks(cookbookId: string) {
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { includeChildren = 'false' } = req.query;
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const accessFilter = buildCookbookAccessFilter(ctx);
|
||||
|
||||
// Get child cookbook IDs to exclude from main listing (unless includeChildren is true)
|
||||
const childCookbookIds = includeChildren === 'true' ? [] : (
|
||||
@@ -213,8 +230,11 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
).map((ci: any) => ci.childCookbookId);
|
||||
|
||||
const cookbooks = await prisma.cookbook.findMany({
|
||||
where: includeChildren === 'true' ? {} : {
|
||||
id: { notIn: childCookbookIds }
|
||||
where: {
|
||||
AND: [
|
||||
accessFilter,
|
||||
includeChildren === 'true' ? {} : { id: { notIn: childCookbookIds } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
@@ -256,9 +276,10 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id },
|
||||
const cookbook = await prisma.cookbook.findFirst({
|
||||
where: { AND: [{ id }, buildCookbookAccessFilter(ctx)] },
|
||||
include: {
|
||||
recipes: {
|
||||
include: {
|
||||
@@ -342,11 +363,15 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const familyId = await getPrimaryFamilyId(req.user!.id);
|
||||
|
||||
const cookbook = await prisma.cookbook.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
coverImageUrl,
|
||||
userId: req.user!.id,
|
||||
familyId,
|
||||
autoFilterCategories: autoFilterCategories || [],
|
||||
autoFilterTags: autoFilterTags || [],
|
||||
autoFilterCookbookTags: autoFilterCookbookTags || [],
|
||||
@@ -388,6 +413,16 @@ router.put('/:id', async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags, autoFilterCookbookTags, tags } = req.body;
|
||||
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const existing = await prisma.cookbook.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, familyId: true },
|
||||
});
|
||||
if (!existing) return res.status(404).json({ error: 'Cookbook not found' });
|
||||
if (!canMutateCookbook(ctx, existing)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
@@ -460,6 +495,15 @@ router.put('/:id', async (req: Request, res: Response) => {
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, familyId: true },
|
||||
});
|
||||
if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' });
|
||||
if (!canMutateCookbook(ctx, cookbook)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await prisma.cookbook.delete({
|
||||
where: { id }
|
||||
@@ -476,6 +520,26 @@ router.delete('/:id', async (req: Request, res: Response) => {
|
||||
router.post('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, recipeId } = req.params;
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, familyId: true },
|
||||
});
|
||||
if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' });
|
||||
if (!canMutateCookbook(ctx, cookbook)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
// Prevent pulling recipes from other tenants into this cookbook.
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: recipeId },
|
||||
select: { userId: true, familyId: true, visibility: true },
|
||||
});
|
||||
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
||||
const sameFamily = !!recipe.familyId && recipe.familyId === cookbook.familyId;
|
||||
const ownedByUser = recipe.userId === ctx.userId;
|
||||
if (ctx.role !== 'ADMIN' && !sameFamily && !ownedByUser) {
|
||||
return res.status(403).json({ error: 'Cannot add recipe from a different tenant' });
|
||||
}
|
||||
|
||||
// Check if recipe is already in cookbook
|
||||
const existing = await prisma.cookbookRecipe.findUnique({
|
||||
@@ -509,6 +573,15 @@ router.post('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||
router.delete('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, recipeId } = req.params;
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, familyId: true },
|
||||
});
|
||||
if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' });
|
||||
if (!canMutateCookbook(ctx, cookbook)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await prisma.cookbookRecipe.delete({
|
||||
where: {
|
||||
@@ -536,6 +609,26 @@ router.post('/:id/cookbooks/:childCookbookId', async (req: Request, res: Respons
|
||||
return res.status(400).json({ error: 'Cannot add cookbook to itself' });
|
||||
}
|
||||
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const parent = await prisma.cookbook.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, familyId: true },
|
||||
});
|
||||
if (!parent) return res.status(404).json({ error: 'Cookbook not found' });
|
||||
if (!canMutateCookbook(ctx, parent)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
const child = await prisma.cookbook.findUnique({
|
||||
where: { id: childCookbookId },
|
||||
select: { userId: true, familyId: true },
|
||||
});
|
||||
if (!child) return res.status(404).json({ error: 'Cookbook not found' });
|
||||
const sameFamily = !!child.familyId && child.familyId === parent.familyId;
|
||||
const ownedByUser = child.userId === ctx.userId;
|
||||
if (ctx.role !== 'ADMIN' && !sameFamily && !ownedByUser) {
|
||||
return res.status(403).json({ error: 'Cannot nest a cookbook from a different tenant' });
|
||||
}
|
||||
|
||||
// Check if cookbook is already included
|
||||
const existing = await prisma.cookbookInclusion.findUnique({
|
||||
where: {
|
||||
@@ -568,6 +661,15 @@ router.post('/:id/cookbooks/:childCookbookId', async (req: Request, res: Respons
|
||||
router.delete('/:id/cookbooks/:childCookbookId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, childCookbookId } = req.params;
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const parent = await prisma.cookbook.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, familyId: true },
|
||||
});
|
||||
if (!parent) return res.status(404).json({ error: 'Cookbook not found' });
|
||||
if (!canMutateCookbook(ctx, parent)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await prisma.cookbookInclusion.delete({
|
||||
where: {
|
||||
@@ -594,10 +696,14 @@ router.post('/:id/image', upload.single('image'), async (req: Request, res: Resp
|
||||
return res.status(400).json({ error: 'No image provided' });
|
||||
}
|
||||
|
||||
// Delete old cover image if it exists
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' });
|
||||
if (!canMutateCookbook(ctx, cookbook)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (cookbook?.coverImageUrl) {
|
||||
await storageService.deleteFile(cookbook.coverImageUrl);
|
||||
@@ -629,10 +735,14 @@ router.post('/:id/image-from-url', async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
// Delete old cover image if it exists
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
if (!cookbook) return res.status(404).json({ error: 'Cookbook not found' });
|
||||
if (!canMutateCookbook(ctx, cookbook)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (cookbook?.coverImageUrl) {
|
||||
await storageService.deleteFile(cookbook.coverImageUrl);
|
||||
|
||||
237
packages/api/src/routes/families.routes.ts
Normal file
237
packages/api/src/routes/families.routes.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import prisma from '../config/database';
|
||||
import { requireAuth } from '../middleware/auth.middleware';
|
||||
import { FamilyRole } from '@prisma/client';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
async function getMembership(userId: string, familyId: string) {
|
||||
return prisma.familyMember.findUnique({
|
||||
where: { userId_familyId: { userId, familyId } },
|
||||
});
|
||||
}
|
||||
|
||||
// List the current user's families.
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const memberships = await prisma.familyMember.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
family: { include: { _count: { select: { members: true } } } },
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
res.json({
|
||||
data: memberships.map((m) => ({
|
||||
id: m.family.id,
|
||||
name: m.family.name,
|
||||
role: m.role,
|
||||
memberCount: m.family._count.members,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching families:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch families' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new family (caller becomes OWNER).
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
const family = await prisma.family.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
members: { create: { userId: req.user!.id, role: 'OWNER' } },
|
||||
},
|
||||
});
|
||||
res.status(201).json({ data: family });
|
||||
} catch (error) {
|
||||
console.error('Error creating family:', error);
|
||||
res.status(500).json({ error: 'Failed to create family' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a family including its members. Must be a member.
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const membership = await getMembership(userId, req.params.id);
|
||||
if (!membership && req.user!.role !== 'ADMIN') {
|
||||
return res.status(404).json({ error: 'Family not found' });
|
||||
}
|
||||
|
||||
const family = await prisma.family.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
members: {
|
||||
include: { user: { select: { id: true, email: true, name: true, avatar: true } } },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!family) return res.status(404).json({ error: 'Family not found' });
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
createdAt: family.createdAt,
|
||||
updatedAt: family.updatedAt,
|
||||
myRole: membership?.role ?? null,
|
||||
members: family.members.map((m) => ({
|
||||
userId: m.userId,
|
||||
email: m.user.email,
|
||||
name: m.user.name,
|
||||
avatar: m.user.avatar,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching family:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch family' });
|
||||
}
|
||||
});
|
||||
|
||||
// Rename a family. OWNER only.
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const membership = await getMembership(userId, req.params.id);
|
||||
const isAdmin = req.user!.role === 'ADMIN';
|
||||
if (!membership || (membership.role !== 'OWNER' && !isAdmin)) {
|
||||
return res.status(403).json({ error: 'Owner access required' });
|
||||
}
|
||||
const { name } = req.body;
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
const family = await prisma.family.update({
|
||||
where: { id: req.params.id },
|
||||
data: { name: name.trim() },
|
||||
});
|
||||
res.json({ data: family });
|
||||
} catch (error) {
|
||||
console.error('Error updating family:', error);
|
||||
res.status(500).json({ error: 'Failed to update family' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a family. OWNER only. Recipes/cookbooks in this family get familyId=NULL.
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const membership = await getMembership(userId, req.params.id);
|
||||
const isAdmin = req.user!.role === 'ADMIN';
|
||||
if (!membership || (membership.role !== 'OWNER' && !isAdmin)) {
|
||||
return res.status(403).json({ error: 'Owner access required' });
|
||||
}
|
||||
await prisma.family.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Family deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting family:', error);
|
||||
res.status(500).json({ error: 'Failed to delete family' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add an existing user to a family by email. OWNER only.
|
||||
router.post('/:id/members', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const membership = await getMembership(userId, req.params.id);
|
||||
const isAdmin = req.user!.role === 'ADMIN';
|
||||
if (!membership || (membership.role !== 'OWNER' && !isAdmin)) {
|
||||
return res.status(403).json({ error: 'Owner access required' });
|
||||
}
|
||||
|
||||
const { email, role } = req.body;
|
||||
if (!email || typeof email !== 'string') {
|
||||
return res.status(400).json({ error: 'Email is required' });
|
||||
}
|
||||
const invitedRole: FamilyRole = role === 'OWNER' ? 'OWNER' : 'MEMBER';
|
||||
|
||||
const invitee = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
select: { id: true, email: true, name: true, avatar: true },
|
||||
});
|
||||
if (!invitee) {
|
||||
return res.status(404).json({ error: 'No user with that email exists on this server' });
|
||||
}
|
||||
|
||||
const existing = await getMembership(invitee.id, req.params.id);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'User is already a member' });
|
||||
}
|
||||
|
||||
const newMember = await prisma.familyMember.create({
|
||||
data: { userId: invitee.id, familyId: req.params.id, role: invitedRole },
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
data: {
|
||||
userId: invitee.id,
|
||||
email: invitee.email,
|
||||
name: invitee.name,
|
||||
avatar: invitee.avatar,
|
||||
role: newMember.role,
|
||||
joinedAt: newMember.joinedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding member:', error);
|
||||
res.status(500).json({ error: 'Failed to add member' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove a member (or leave as self). OWNER can remove anyone; a member can only remove themselves.
|
||||
router.delete('/:id/members/:userId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const currentUserId = req.user!.id;
|
||||
const targetUserId = req.params.userId;
|
||||
const membership = await getMembership(currentUserId, req.params.id);
|
||||
const isAdmin = req.user!.role === 'ADMIN';
|
||||
|
||||
if (!membership && !isAdmin) {
|
||||
return res.status(403).json({ error: 'Not a member of this family' });
|
||||
}
|
||||
|
||||
const isOwner = membership?.role === 'OWNER';
|
||||
const isSelf = targetUserId === currentUserId;
|
||||
if (!isOwner && !isSelf && !isAdmin) {
|
||||
return res.status(403).json({ error: 'Only owners can remove other members' });
|
||||
}
|
||||
|
||||
const target = await getMembership(targetUserId, req.params.id);
|
||||
if (!target) {
|
||||
return res.status(404).json({ error: 'Member not found' });
|
||||
}
|
||||
|
||||
// Don't let the last OWNER leave/be removed — would orphan the family.
|
||||
if (target.role === 'OWNER') {
|
||||
const ownerCount = await prisma.familyMember.count({
|
||||
where: { familyId: req.params.id, role: 'OWNER' },
|
||||
});
|
||||
if (ownerCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot remove the last owner; transfer ownership or delete the family first' });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.familyMember.delete({
|
||||
where: { userId_familyId: { userId: targetUserId, familyId: req.params.id } },
|
||||
});
|
||||
res.json({ message: 'Member removed' });
|
||||
} catch (error) {
|
||||
console.error('Error removing member:', error);
|
||||
res.status(500).json({ error: 'Failed to remove member' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -4,9 +4,17 @@ import prisma from '../config/database';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
import { ScraperService } from '../services/scraper.service';
|
||||
import { autoMapIngredients, saveIngredientMappings } from '../services/ingredientMatcher.service';
|
||||
import {
|
||||
getAccessContext,
|
||||
buildRecipeAccessFilter,
|
||||
canMutateRecipe,
|
||||
getPrimaryFamilyId,
|
||||
} from '../services/access.service';
|
||||
import { requireAuth } from '../middleware/auth.middleware';
|
||||
import { ApiResponse, RecipeImportRequest } from '@basil/shared';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
@@ -23,7 +31,8 @@ const upload = multer({
|
||||
const storageService = StorageService.getInstance();
|
||||
const scraperService = new ScraperService();
|
||||
|
||||
// Helper function to auto-add recipe to cookbooks based on their filters
|
||||
// Helper function to auto-add recipe to cookbooks based on their filters.
|
||||
// Scoped to same family to prevent cross-tenant leakage via shared tag names.
|
||||
async function autoAddToCookbooks(recipeId: string) {
|
||||
try {
|
||||
// Get the recipe with its category and tags
|
||||
@@ -43,9 +52,11 @@ async function autoAddToCookbooks(recipeId: string) {
|
||||
const recipeTags = recipe.tags.map((rt: any) => rt.tag.name);
|
||||
const recipeCategories = recipe.categories || [];
|
||||
|
||||
// Get all cookbooks with auto-filters
|
||||
// Get cookbooks in the same family with auto-filters. Skip unscoped recipes.
|
||||
if (!recipe.familyId) return;
|
||||
const cookbooks = await prisma.cookbook.findMany({
|
||||
where: {
|
||||
familyId: recipe.familyId,
|
||||
OR: [
|
||||
{ autoFilterCategories: { isEmpty: false } },
|
||||
{ autoFilterTags: { isEmpty: false } }
|
||||
@@ -107,36 +118,35 @@ router.get('/', async (req, res) => {
|
||||
const limitNum = parseInt(limit as string);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = {};
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const where: any = { AND: [buildRecipeAccessFilter(ctx)] };
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||
{ description: { contains: search as string, mode: 'insensitive' } },
|
||||
{
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name: { contains: search as string, mode: 'insensitive' }
|
||||
where.AND.push({
|
||||
OR: [
|
||||
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||
{ description: { contains: search as string, mode: 'insensitive' } },
|
||||
{
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name: { contains: search as string, mode: 'insensitive' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
if (cuisine) where.cuisine = cuisine;
|
||||
if (category) {
|
||||
where.categories = {
|
||||
has: category as string
|
||||
};
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (cuisine) where.AND.push({ cuisine });
|
||||
if (category) where.AND.push({ categories: { has: category as string } });
|
||||
if (tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
tag: {
|
||||
name: { equals: tag as string, mode: 'insensitive' }
|
||||
}
|
||||
}
|
||||
};
|
||||
where.AND.push({
|
||||
tags: {
|
||||
some: {
|
||||
tag: { name: { equals: tag as string, mode: 'insensitive' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [recipes, total] = await Promise.all([
|
||||
@@ -215,8 +225,9 @@ router.get('/', async (req, res) => {
|
||||
// Get single recipe
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const recipe = await prisma.recipe.findFirst({
|
||||
where: { AND: [{ id: req.params.id }, buildRecipeAccessFilter(ctx)] },
|
||||
include: {
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
@@ -285,11 +296,17 @@ router.get('/:id', async (req, res) => {
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { title, description, sections, ingredients, instructions, tags, ...recipeData } = req.body;
|
||||
// Strip any client-supplied ownership — always derive server-side.
|
||||
delete recipeData.userId;
|
||||
delete recipeData.familyId;
|
||||
const familyId = await getPrimaryFamilyId(req.user!.id);
|
||||
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
userId: req.user!.id,
|
||||
familyId,
|
||||
...recipeData,
|
||||
sections: sections
|
||||
? {
|
||||
@@ -361,7 +378,20 @@ router.post('/', async (req, res) => {
|
||||
// Update recipe
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const existing = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { userId: true, familyId: true, visibility: true },
|
||||
});
|
||||
if (!existing) return res.status(404).json({ error: 'Recipe not found' });
|
||||
if (!canMutateRecipe(ctx, existing)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const { sections, ingredients, instructions, tags, ...recipeData } = req.body;
|
||||
// Block client from reassigning ownership via update.
|
||||
delete recipeData.userId;
|
||||
delete recipeData.familyId;
|
||||
|
||||
// Only delete relations that are being updated (not undefined)
|
||||
if (sections !== undefined) {
|
||||
@@ -465,20 +495,23 @@ router.put('/:id', async (req, res) => {
|
||||
// Delete recipe
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
// Get recipe to delete associated images
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { images: true },
|
||||
});
|
||||
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
||||
if (!canMutateRecipe(ctx, recipe)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (recipe) {
|
||||
// Delete images from storage
|
||||
if (recipe.imageUrl) {
|
||||
await storageService.deleteFile(recipe.imageUrl);
|
||||
}
|
||||
for (const image of recipe.images) {
|
||||
await storageService.deleteFile(image.url);
|
||||
}
|
||||
// Delete images from storage
|
||||
if (recipe.imageUrl) {
|
||||
await storageService.deleteFile(recipe.imageUrl);
|
||||
}
|
||||
for (const image of recipe.images) {
|
||||
await storageService.deleteFile(image.url);
|
||||
}
|
||||
|
||||
await prisma.recipe.delete({ where: { id: req.params.id } });
|
||||
@@ -505,16 +538,20 @@ router.post('/:id/images', upload.single('image'), async (req, res) => {
|
||||
return res.status(400).json({ error: 'No image provided' });
|
||||
}
|
||||
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const existingRecipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { imageUrl: true, userId: true, familyId: true, visibility: true },
|
||||
});
|
||||
if (!existingRecipe) return res.status(404).json({ error: 'Recipe not found' });
|
||||
if (!canMutateRecipe(ctx, existingRecipe)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
console.log('Saving file to storage...');
|
||||
const imageUrl = await storageService.saveFile(req.file, 'recipes');
|
||||
console.log('File saved, URL:', imageUrl);
|
||||
|
||||
// Get existing recipe to delete old image
|
||||
const existingRecipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { imageUrl: true },
|
||||
});
|
||||
|
||||
// Delete old image from storage if it exists
|
||||
if (existingRecipe?.imageUrl) {
|
||||
console.log('Deleting old image:', existingRecipe.imageUrl);
|
||||
@@ -550,12 +587,17 @@ router.post('/:id/images', upload.single('image'), async (req, res) => {
|
||||
// Delete recipe image
|
||||
router.delete('/:id/image', async (req, res) => {
|
||||
try {
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { imageUrl: true },
|
||||
select: { imageUrl: true, userId: true, familyId: true, visibility: true },
|
||||
});
|
||||
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
||||
if (!canMutateRecipe(ctx, recipe)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (!recipe?.imageUrl) {
|
||||
if (!recipe.imageUrl) {
|
||||
return res.status(404).json({ error: 'No image to delete' });
|
||||
}
|
||||
|
||||
@@ -606,6 +648,16 @@ router.post('/:id/ingredient-mappings', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Mappings must be an array' });
|
||||
}
|
||||
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { userId: true, familyId: true, visibility: true },
|
||||
});
|
||||
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
||||
if (!canMutateRecipe(ctx, recipe)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await saveIngredientMappings(mappings);
|
||||
|
||||
res.json({ message: 'Mappings updated successfully' });
|
||||
@@ -618,6 +670,16 @@ router.post('/:id/ingredient-mappings', async (req, res) => {
|
||||
// Regenerate ingredient-instruction mappings
|
||||
router.post('/:id/regenerate-mappings', async (req, res) => {
|
||||
try {
|
||||
const ctx = await getAccessContext(req.user!);
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { userId: true, familyId: true, visibility: true },
|
||||
});
|
||||
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
|
||||
if (!canMutateRecipe(ctx, recipe)) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
await autoMapIngredients(req.params.id);
|
||||
|
||||
res.json({ message: 'Mappings regenerated successfully' });
|
||||
|
||||
155
packages/api/src/scripts/backfill-family-tenant.ts
Normal file
155
packages/api/src/scripts/backfill-family-tenant.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Backfill default families for existing data.
|
||||
*
|
||||
* For every user, ensure they have a personal Family (as OWNER).
|
||||
* Any Recipe or Cookbook that they own (userId = them) but has no familyId
|
||||
* is assigned to that family.
|
||||
*
|
||||
* Orphan content (userId IS NULL) is assigned to --owner (default: first ADMIN user)
|
||||
* so existing legacy records don't disappear behind the access filter.
|
||||
*
|
||||
* Idempotent — safe to re-run.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx src/scripts/backfill-family-tenant.ts
|
||||
* npx tsx src/scripts/backfill-family-tenant.ts --owner admin@basil.local
|
||||
* npx tsx src/scripts/backfill-family-tenant.ts --dry-run
|
||||
*/
|
||||
|
||||
import { PrismaClient, User, Family } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface Options {
|
||||
ownerEmail?: string;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(): Options {
|
||||
const args = process.argv.slice(2);
|
||||
const opts: Options = { dryRun: false };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--dry-run') opts.dryRun = true;
|
||||
else if (args[i] === '--owner' && args[i + 1]) {
|
||||
opts.ownerEmail = args[++i];
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function ensurePersonalFamily(user: User, dryRun: boolean): Promise<Family> {
|
||||
const existing = await prisma.familyMember.findFirst({
|
||||
where: { userId: user.id, role: 'OWNER' },
|
||||
include: { family: true },
|
||||
});
|
||||
if (existing) return existing.family;
|
||||
|
||||
const name = `${user.name || user.email.split('@')[0]}'s Family`;
|
||||
if (dryRun) {
|
||||
console.log(` [dry-run] would create Family "${name}" for ${user.email}`);
|
||||
return { id: '<dry-run>', name, createdAt: new Date(), updatedAt: new Date() };
|
||||
}
|
||||
|
||||
const family = await prisma.family.create({
|
||||
data: {
|
||||
name,
|
||||
members: {
|
||||
create: { userId: user.id, role: 'OWNER' },
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(` Created Family "${family.name}" (${family.id}) for ${user.email}`);
|
||||
return family;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
console.log(`\n🌿 Family tenant backfill${opts.dryRun ? ' [DRY RUN]' : ''}\n`);
|
||||
|
||||
// 1. Pick legacy owner for orphan records.
|
||||
let legacyOwner: User | null = null;
|
||||
if (opts.ownerEmail) {
|
||||
legacyOwner = await prisma.user.findUnique({ where: { email: opts.ownerEmail.toLowerCase() } });
|
||||
if (!legacyOwner) {
|
||||
console.error(`❌ No user with email ${opts.ownerEmail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
legacyOwner = await prisma.user.findFirst({
|
||||
where: { role: 'ADMIN' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!legacyOwner) {
|
||||
console.warn('⚠️ No admin user found; orphan recipes/cookbooks will be left with familyId = NULL');
|
||||
} else {
|
||||
console.log(`Legacy owner for orphan content: ${legacyOwner.email}\n`);
|
||||
}
|
||||
|
||||
// 2. Ensure every user has a personal family.
|
||||
const users = await prisma.user.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
console.log(`Processing ${users.length} user(s):`);
|
||||
const userFamily = new Map<string, Family>();
|
||||
for (const u of users) {
|
||||
const fam = await ensurePersonalFamily(u, opts.dryRun);
|
||||
userFamily.set(u.id, fam);
|
||||
}
|
||||
|
||||
// 3. Backfill Recipe.familyId and Cookbook.familyId.
|
||||
const targets = [
|
||||
{ label: 'Recipe', model: prisma.recipe },
|
||||
{ label: 'Cookbook', model: prisma.cookbook },
|
||||
] as const;
|
||||
|
||||
let totalUpdated = 0;
|
||||
|
||||
for (const { label, model } of targets) {
|
||||
// Owned content without a familyId — assign to owner's family.
|
||||
const ownedRows: { id: string; userId: string | null }[] = await (model as any).findMany({
|
||||
where: { familyId: null, userId: { not: null } },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
|
||||
for (const row of ownedRows) {
|
||||
const fam = userFamily.get(row.userId!);
|
||||
if (!fam) continue;
|
||||
if (!opts.dryRun) {
|
||||
await (model as any).update({ where: { id: row.id }, data: { familyId: fam.id } });
|
||||
}
|
||||
totalUpdated++;
|
||||
}
|
||||
console.log(` ${label}: ${ownedRows.length} owned row(s) assigned to owner's family`);
|
||||
|
||||
// Orphan content — assign to legacy owner's family if configured.
|
||||
if (legacyOwner) {
|
||||
const legacyFam = userFamily.get(legacyOwner.id)!;
|
||||
const orphans: { id: string }[] = await (model as any).findMany({
|
||||
where: { familyId: null, userId: null },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const row of orphans) {
|
||||
if (!opts.dryRun) {
|
||||
await (model as any).update({
|
||||
where: { id: row.id },
|
||||
data: { familyId: legacyFam.id, userId: legacyOwner.id },
|
||||
});
|
||||
}
|
||||
totalUpdated++;
|
||||
}
|
||||
console.log(` ${label}: ${orphans.length} orphan row(s) assigned to ${legacyOwner.email}'s family`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Backfill complete (${totalUpdated} row(s) ${opts.dryRun ? 'would be ' : ''}updated)\n`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('❌ Backfill failed:', err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
108
packages/api/src/services/access.service.ts
Normal file
108
packages/api/src/services/access.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import prisma from '../config/database';
|
||||
|
||||
export interface AccessContext {
|
||||
userId: string;
|
||||
role: 'USER' | 'ADMIN';
|
||||
familyIds: string[];
|
||||
}
|
||||
|
||||
export async function getAccessContext(user: User): Promise<AccessContext> {
|
||||
const memberships = await prisma.familyMember.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { familyId: true },
|
||||
});
|
||||
return {
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
familyIds: memberships.map((m) => m.familyId),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRecipeAccessFilter(ctx: AccessContext): Prisma.RecipeWhereInput {
|
||||
if (ctx.role === 'ADMIN') return {};
|
||||
return {
|
||||
OR: [
|
||||
{ userId: ctx.userId },
|
||||
{ familyId: { in: ctx.familyIds } },
|
||||
{ visibility: 'PUBLIC' },
|
||||
{ sharedWith: { some: { userId: ctx.userId } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCookbookAccessFilter(ctx: AccessContext): Prisma.CookbookWhereInput {
|
||||
if (ctx.role === 'ADMIN') return {};
|
||||
return {
|
||||
OR: [
|
||||
{ userId: ctx.userId },
|
||||
{ familyId: { in: ctx.familyIds } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
type RecipeAccessSubject = {
|
||||
userId: string | null;
|
||||
familyId: string | null;
|
||||
visibility: 'PRIVATE' | 'SHARED' | 'PUBLIC';
|
||||
};
|
||||
|
||||
type CookbookAccessSubject = {
|
||||
userId: string | null;
|
||||
familyId: string | null;
|
||||
};
|
||||
|
||||
export function canReadRecipe(
|
||||
ctx: AccessContext,
|
||||
recipe: RecipeAccessSubject,
|
||||
sharedUserIds: string[] = [],
|
||||
): boolean {
|
||||
if (ctx.role === 'ADMIN') return true;
|
||||
if (recipe.userId === ctx.userId) return true;
|
||||
if (recipe.familyId && ctx.familyIds.includes(recipe.familyId)) return true;
|
||||
if (recipe.visibility === 'PUBLIC') return true;
|
||||
if (sharedUserIds.includes(ctx.userId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canMutateRecipe(
|
||||
ctx: AccessContext,
|
||||
recipe: RecipeAccessSubject,
|
||||
): boolean {
|
||||
if (ctx.role === 'ADMIN') return true;
|
||||
if (recipe.userId === ctx.userId) return true;
|
||||
if (recipe.familyId && ctx.familyIds.includes(recipe.familyId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canReadCookbook(
|
||||
ctx: AccessContext,
|
||||
cookbook: CookbookAccessSubject,
|
||||
): boolean {
|
||||
if (ctx.role === 'ADMIN') return true;
|
||||
if (cookbook.userId === ctx.userId) return true;
|
||||
if (cookbook.familyId && ctx.familyIds.includes(cookbook.familyId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canMutateCookbook(
|
||||
ctx: AccessContext,
|
||||
cookbook: CookbookAccessSubject,
|
||||
): boolean {
|
||||
return canReadCookbook(ctx, cookbook);
|
||||
}
|
||||
|
||||
export async function getPrimaryFamilyId(userId: string): Promise<string | null> {
|
||||
const owner = await prisma.familyMember.findFirst({
|
||||
where: { userId, role: 'OWNER' },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
select: { familyId: true },
|
||||
});
|
||||
if (owner) return owner.familyId;
|
||||
const any = await prisma.familyMember.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
select: { familyId: true },
|
||||
});
|
||||
return any?.familyId ?? null;
|
||||
}
|
||||
@@ -3,4 +3,4 @@
|
||||
* Example: 2026.01.002 (January 2026, patch 2), 2026.02.003 (February 2026, patch 3)
|
||||
* Month and patch are zero-padded. Patch increments with each deployment in a month.
|
||||
*/
|
||||
export const APP_VERSION = '2026.01.006';
|
||||
export const APP_VERSION = '2026.04.008';
|
||||
|
||||
Reference in New Issue
Block a user