diff --git a/client/src/pages/dashboard.tsx b/client/src/pages/dashboard.tsx index 1d66b32..8bb05ec 100644 --- a/client/src/pages/dashboard.tsx +++ b/client/src/pages/dashboard.tsx @@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input"; import { useUser } from "@/hooks/useUser"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Trash2 } from "lucide-react"; +import { useFiles } from "@/hooks/useFiles"; //import { sendContactNotification } from "@/lib/mail"; // Removed as it's not relevant to items export default function Dashboard() { @@ -23,6 +24,8 @@ export default function Dashboard() { const [isNewItemOpen, setIsNewItemOpen] = useState(false); const [newItem, setNewItem] = useState(""); const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); + const [file, setFile] = useState(null); + const { uploadFile } = useFiles(); // Handle checkout success from URL params useEffect(() => { @@ -67,15 +70,21 @@ export default function Dashboard() { }); const addItemMutation = useMutation({ - mutationFn: async (item: string) => { - await apiRequest('POST', '/api/items', { + mutationFn: async ({ item, file }: { item: string; file?: File }) => { + const res = await apiRequest('POST', '/api/items', { userId: firebaseUser?.uid, item: item.trim() }); + const created = await res.json(); + if (file) { + const uploaded = await uploadFile(file); + await apiRequest('POST', `/api/items/${created.id}/files`, { fileId: uploaded.id }); + } }, onSuccess: () => { refetch(); setNewItem(''); + setFile(null); setIsNewItemOpen(false); toast({ title: "Success", @@ -124,7 +133,7 @@ export default function Dashboard() { if (!firebaseUser?.uid || !newItem.trim()) return; try { - await addItemMutation.mutateAsync(newItem); + await addItemMutation.mutateAsync({ item: newItem, file: file || undefined }); } catch (error) { toast({ title: "Error", @@ -154,6 +163,7 @@ export default function Dashboard() { value={newItem} onChange={(e) => setNewItem(e.target.value)} /> + setFile(e.target.files?.[0] || null)} />
diff --git a/jest.setup.js b/jest.setup.js index 2310a9b..b4d133d 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -82,8 +82,11 @@ jest.mock('./server/storage/index', () => ({ createUser: jest.fn(), updateUser: jest.fn(), getItemsByUserId: jest.fn().mockResolvedValue([]), + getItemsWithFilesByUserId: jest.fn().mockResolvedValue([]), createItem: jest.fn(), deleteItem: jest.fn(), + addFileToItem: jest.fn(), + getFilesByItemId: jest.fn().mockResolvedValue([]), getFilesByUserId: jest.fn().mockResolvedValue([]), getFileById: jest.fn(), getFileByIdAndUserId: jest.fn(), diff --git a/migrations/0002_melted_blink.sql b/migrations/0002_melted_blink.sql new file mode 100644 index 0000000..e8cfe60 --- /dev/null +++ b/migrations/0002_melted_blink.sql @@ -0,0 +1,8 @@ +CREATE TABLE "item_files" ( + "id" serial PRIMARY KEY NOT NULL, + "item_id" integer NOT NULL, + "file_id" integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE "item_files" ADD CONSTRAINT "item_files_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "item_files" ADD CONSTRAINT "item_files_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..afcb64a --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,430 @@ +{ + "id": "fe3b2f0d-7546-4039-b130-653bd4820f6c", + "prevId": "01d9ba59-fd6b-4d61-870c-57db321f2193", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_messages": { + "name": "ai_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ai_messages_thread_id_ai_threads_id_fk": { + "name": "ai_messages_thread_id_ai_threads_id_fk", + "tableFrom": "ai_messages", + "tableTo": "ai_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_threads": { + "name": "ai_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New Chat'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ai_threads_user_id_users_firebase_id_fk": { + "name": "ai_threads_user_id_users_firebase_id_fk", + "tableFrom": "ai_threads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "firebase_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "files_user_id_users_firebase_id_fk": { + "name": "files_user_id_users_firebase_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "firebase_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.item_files": { + "name": "item_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "item_files_item_id_items_id_fk": { + "name": "item_files_item_id_items_id_fk", + "tableFrom": "item_files", + "tableTo": "items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_files_file_id_files_id_fk": { + "name": "item_files_file_id_files_id_fk", + "tableFrom": "item_files", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.items": { + "name": "items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "item": { + "name": "item", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "items_user_id_users_firebase_id_fk": { + "name": "items_user_id_users_firebase_id_fk", + "tableFrom": "items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "firebase_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "firebase_id": { + "name": "firebase_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_premium": { + "name": "is_premium", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "subscription_type": { + "name": "subscription_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "email_notifications": { + "name": "email_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index ca84dbe..b30e5de 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1754886732573, "tag": "0001_cute_gideon", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1755185464351, + "tag": "0002_melted_blink", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/__tests__/setup/mocks.ts b/server/__tests__/setup/mocks.ts index 5a1dfae..5f48a8a 100644 --- a/server/__tests__/setup/mocks.ts +++ b/server/__tests__/setup/mocks.ts @@ -92,12 +92,15 @@ export const resetAllMocks = () => { mockStorage.getUserByFirebaseId.mockResolvedValue(null); mockStorage.getUserByEmail.mockResolvedValue(null); mockStorage.getItemsByUserId.mockResolvedValue([]); + mockStorage.getItemsWithFilesByUserId.mockResolvedValue([]); mockStorage.getFilesByUserId.mockResolvedValue([]); mockStorage.getFileById.mockResolvedValue(null); mockStorage.createUser.mockResolvedValue({ id: 1, firebaseId: 'test-firebase-uid' }); mockStorage.updateUser.mockResolvedValue({ id: 1, firebaseId: 'test-firebase-uid' }); mockStorage.createItem.mockResolvedValue({ id: 1, item: 'test', userId: 'test-firebase-uid' }); mockStorage.deleteItem.mockResolvedValue(undefined); + mockStorage.addFileToItem.mockResolvedValue(undefined); + mockStorage.getFilesByItemId.mockResolvedValue([]); mockStorage.createFile.mockResolvedValue({ id: 1, name: 'test.jpg', userId: 'test-firebase-uid' }); mockStorage.deleteFile.mockResolvedValue(undefined); mockStorage.getFileByPath.mockResolvedValue(null); diff --git a/server/__tests__/storage.test.ts b/server/__tests__/storage.test.ts index 740445f..ec9a661 100644 --- a/server/__tests__/storage.test.ts +++ b/server/__tests__/storage.test.ts @@ -404,6 +404,32 @@ describe('Storage Layer', () => { }); }); + describe('Item-File Relationships', () => { + it('should link a file to an item', async () => { + mockStorage.addFileToItem.mockResolvedValue(undefined); + await expect(mockStorage.addFileToItem(1, 2)).resolves.toBeUndefined(); + expect(mockStorage.addFileToItem).toHaveBeenCalledWith(1, 2); + }); + + it('should fetch files for an item', async () => { + const mockFiles = [ + { id: 1, name: 'f.txt', originalName: 'f.txt', path: 'p', url: 'u', size: 1, type: 'text/plain', userId: 'uid' } + ]; + mockStorage.getFilesByItemId.mockResolvedValue(mockFiles as any); + const result = await mockStorage.getFilesByItemId(1); + expect(result).toEqual(mockFiles); + }); + + it('should fetch items with files for a user', async () => { + const mockItems = [ + { id: 1, item: 'test', userId: 'uid', files: [] } + ]; + mockStorage.getItemsWithFilesByUserId.mockResolvedValue(mockItems as any); + const result = await mockStorage.getItemsWithFilesByUserId('uid'); + expect(result).toEqual(mockItems); + }); + }); + describe('Data Integrity and Security', () => { it('should enforce user ownership in all operations', async () => { const userId = 'test-firebase-uid'; diff --git a/server/routes/itemRoutes.ts b/server/routes/itemRoutes.ts index 3de7d2d..9aabaf6 100644 --- a/server/routes/itemRoutes.ts +++ b/server/routes/itemRoutes.ts @@ -15,11 +15,15 @@ const createItemSchema = z.object({ item: z.string().min(1).max(1000).trim() }); +const addFileSchema = z.object({ + fileId: z.number().int() +}); + export async function registerItemRoutes(app: Express) { app.get("/api/items", requireAuth, requiresOwnership, async (req: AuthenticatedRequest, res) => { try { const userId = req.user!.uid; - const items = await storage.getItemsByUserId(userId); + const items = await storage.getItemsWithFilesByUserId(userId); res.json(items || []); } catch (error) { console.error("Error fetching items:", error); @@ -76,7 +80,7 @@ export async function registerItemRoutes(app: Express) { try { // Validate item ID parameter const { id } = itemIdSchema.parse(req.params); - + await storage.deleteItem(id); res.status(204).send(); } catch (error) { @@ -84,4 +88,34 @@ export async function registerItemRoutes(app: Express) { handleError(error, res); } }); + + app.post("/api/items/:id/files", requireAuth, requiresItemOwnership, async (req: AuthenticatedRequest, res) => { + try { + const { id } = itemIdSchema.parse(req.params); + const { fileId } = addFileSchema.parse(req.body); + const userId = req.user!.uid; + + const file = await storage.getFileById(fileId); + if (!file || file.userId !== userId) { + throw errors.notFound("File"); + } + + await storage.addFileToItem(id, fileId); + res.status(204).send(); + } catch (error) { + console.error("[Items] Error adding file to item:", error); + handleError(error, res); + } + }); + + app.get("/api/items/:id/files", requireAuth, requiresItemOwnership, async (req: AuthenticatedRequest, res) => { + try { + const { id } = itemIdSchema.parse(req.params); + const files = await storage.getFilesByItemId(id); + res.json(files || []); + } catch (error) { + console.error("[Items] Error fetching item files:", error); + handleError(error, res); + } + }); } diff --git a/server/storage/ItemStorage.ts b/server/storage/ItemStorage.ts index 8acb456..37c0e44 100644 --- a/server/storage/ItemStorage.ts +++ b/server/storage/ItemStorage.ts @@ -1,4 +1,4 @@ -import { type Item, type InsertItem, items } from "@shared/schema"; +import { type Item, type InsertItem, type File, items, itemFiles, files } from "@shared/schema"; import { eq } from "drizzle-orm"; import { db } from "../db"; @@ -7,6 +7,27 @@ export class ItemStorage { return db.select().from(items).where(eq(items.userId, userId)); } + async getItemsWithFilesByUserId(userId: string): Promise<(Item & { files: File[] })[]> { + const rows = await db + .select({ item: items, file: files }) + .from(items) + .leftJoin(itemFiles, eq(itemFiles.itemId, items.id)) + .leftJoin(files, eq(itemFiles.fileId, files.id)) + .where(eq(items.userId, userId)); + + const map = new Map(); + for (const row of rows) { + const item = row.item; + if (!map.has(item.id)) { + map.set(item.id, { ...item, files: [] }); + } + if (row.file) { + map.get(item.id)!.files.push(row.file); + } + } + return Array.from(map.values()); + } + async createItem(item: InsertItem): Promise { const [newItem] = await db.insert(items).values(item).returning(); return newItem; @@ -15,4 +36,17 @@ export class ItemStorage { async deleteItem(id: number): Promise { await db.delete(items).where(eq(items.id, id)); } + + async addFileToItem(itemId: number, fileId: number): Promise { + await db.insert(itemFiles).values({ itemId, fileId }); + } + + async getFilesByItemId(itemId: number): Promise { + const rows = await db + .select({ file: files }) + .from(itemFiles) + .innerJoin(files, eq(itemFiles.fileId, files.id)) + .where(eq(itemFiles.itemId, itemId)); + return rows.map((r) => r.file); + } } \ No newline at end of file diff --git a/server/storage/index.ts b/server/storage/index.ts index b26ce87..4819c64 100644 --- a/server/storage/index.ts +++ b/server/storage/index.ts @@ -5,6 +5,8 @@ import { ThreadStorage } from './ThreadStorage'; import { MessageStorage } from './MessageStorage'; import { type Item, type InsertItem, type User, type InsertUser, type File, type InsertFile, type AiThread, type InsertAiThread, type AiMessage, type InsertAiMessage } from "@shared/schema"; +export type ItemWithFiles = Item & { files: File[] }; + interface UpdateUserData { firstName?: string; lastName?: string; @@ -27,8 +29,11 @@ export interface IStorage { // Item operations getItemsByUserId(userId: string): Promise; + getItemsWithFilesByUserId(userId: string): Promise; createItem(item: InsertItem): Promise; deleteItem(id: number): Promise; + addFileToItem(itemId: number, fileId: number): Promise; + getFilesByItemId(itemId: number): Promise; // File operations getFilesByUserId(userId: string): Promise; @@ -94,6 +99,10 @@ export class PostgresStorage implements IStorage { return this.itemStorage.getItemsByUserId(userId); } + async getItemsWithFilesByUserId(userId: string): Promise { + return this.itemStorage.getItemsWithFilesByUserId(userId); + } + async createItem(item: InsertItem): Promise { return this.itemStorage.createItem(item); } @@ -102,6 +111,14 @@ export class PostgresStorage implements IStorage { return this.itemStorage.deleteItem(id); } + async addFileToItem(itemId: number, fileId: number): Promise { + return this.itemStorage.addFileToItem(itemId, fileId); + } + + async getFilesByItemId(itemId: number): Promise { + return this.itemStorage.getFilesByItemId(itemId); + } + // File operations async getFilesByUserId(userId: string): Promise { return this.fileStorage.getFilesByUserId(userId); diff --git a/shared/schema.ts b/shared/schema.ts index 7c8ede0..ae1b52e 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -45,6 +45,12 @@ export const files = pgTable("files", { updatedAt: timestamp("updated_at").notNull().defaultNow(), }); +export const itemFiles = pgTable("item_files", { + id: serial("id").primaryKey(), + itemId: integer("item_id").notNull().references(() => items.id, { onDelete: 'cascade' }), + fileId: integer("file_id").notNull().references(() => files.id, { onDelete: 'cascade' }), +}); + export const aiThreads = pgTable("ai_threads", { id: text("id").primaryKey(), title: text("title").notNull().default("New Chat"), @@ -68,18 +74,31 @@ export const usersRelations = relations(users, ({ many }) => ({ aiThreads: many(aiThreads), })); -export const itemsRelations = relations(items, ({ one }) => ({ +export const itemsRelations = relations(items, ({ one, many }) => ({ user: one(users, { fields: [items.userId], references: [users.firebaseId], }), + itemFiles: many(itemFiles), })); -export const filesRelations = relations(files, ({ one }) => ({ +export const filesRelations = relations(files, ({ one, many }) => ({ user: one(users, { fields: [files.userId], references: [users.firebaseId], }), + itemFiles: many(itemFiles), +})); + +export const itemFilesRelations = relations(itemFiles, ({ one }) => ({ + item: one(items, { + fields: [itemFiles.itemId], + references: [items.id], + }), + file: one(files, { + fields: [itemFiles.fileId], + references: [files.id], + }), })); export const aiThreadsRelations = relations(aiThreads, ({ one, many }) => ({ @@ -126,6 +145,11 @@ export const insertFileSchema = createInsertSchema(files, { userId: z.string(), }); +export const insertItemFileSchema = createInsertSchema(itemFiles, { + itemId: z.number(), + fileId: z.number(), +}); + export const insertAiThreadSchema = createInsertSchema(aiThreads, { id: z.string(), title: z.string().default("New Chat"), @@ -146,6 +170,8 @@ export type InsertItem = z.infer; export type Item = typeof items.$inferSelect; export type InsertFile = z.infer; export type File = typeof files.$inferSelect; +export type InsertItemFile = z.infer; +export type ItemFile = typeof itemFiles.$inferSelect; export type InsertAiThread = z.infer; export type AiThread = typeof aiThreads.$inferSelect; export type InsertAiMessage = z.infer;