From 286a3e65eab72daefd1cae25dfab42266cbd8d8a Mon Sep 17 00:00:00 2001 From: Henry Inman Date: Sun, 26 Jul 2026 20:27:36 -0700 Subject: [PATCH 1/4] Add wardrobe statistics and development test data --- seed-stats.sql | 68 +++++++++++++++++++++++ src/auth/conditional-auth.guard.ts | 4 +- src/dal/entity/outfit.entity.ts | 10 +++- src/wardrobe/garment.service.ts | 84 +++++++++++++++++++++-------- src/wardrobe/stats.controller.ts | 24 +++++++++ src/wardrobe/wardrobe.controller.ts | 1 + src/wardrobe/wardrobe.module.ts | 3 +- views/partials/dock.hbs | 7 +++ views/stats/index.hbs | 35 ++++++++++++ 9 files changed, 210 insertions(+), 26 deletions(-) create mode 100644 seed-stats.sql create mode 100644 src/wardrobe/stats.controller.ts create mode 100644 views/stats/index.hbs diff --git a/seed-stats.sql b/seed-stats.sql new file mode 100644 index 0000000..40f5bdb --- /dev/null +++ b/seed-stats.sql @@ -0,0 +1,68 @@ +PRAGMA foreign_keys=OFF; + +DELETE FROM outfit_garments; +DELETE FROM outfit_calendar; +DELETE FROM outfit; +DELETE FROM garment; +DELETE FROM user; + +INSERT INTO user +(id, shareable_id, email, password, first_name, last_name) +VALUES +(1, 'user-test-001', 'test@example.com', 'testpassword', 'Test', 'User'); + + +INSERT INTO garment +(id, shareable_id, name, category, owner_id, archived, brand, color, size) +VALUES +(1,'garment-001','Black T-Shirt','shirt',1,0,'Uniqlo',1,'M'), +(2,'garment-002','Blue Hoodie','hoodie',1,0,'Patagonia',2,'M'), +(3,'garment-003','Jeans','pants',1,0,'Levis',3,'32'), +(4,'garment-004','White Button Shirt','shirt',1,0,'Everlane',4,'M'), +(5,'garment-005','Brown Boots','shoes',1,0,'Red Wing',5,'10'), +(6,'garment-006','Green Jacket','jacket',1,0,'North Face',6,'M'), +(7,'garment-007','Grey Sweater','sweater',1,0,'J Crew',7,'M'), +(8,'garment-008','Black Chinos','pants',1,0,'Dockers',8,'32'), +(9,'garment-009','Running Shoes','shoes',1,0,'Nike',9,'10'), +(10,'garment-010','Denim Jacket','jacket',1,0,'Levis',10,'M'); + + +INSERT INTO outfit +(id, shareable_id, name, owner_id, notes) +VALUES +(1,'outfit-001','Casual Day',1,'frequently worn'), +(2,'outfit-002','Work Outfit',1,'office'), +(3,'outfit-003','Weekend',1,'relaxed'), +(4,'outfit-004','Rainy Day',1,'weather gear'), +(5,'outfit-005','Night Out',1,'dressy'); + + +INSERT INTO outfit_garments VALUES +(1,1),(1,2),(1,3), +(2,4),(2,8),(2,5), +(3,2),(3,7),(3,9), +(4,6),(4,2),(4,5), +(5,4),(5,5),(5,9); + + +INSERT INTO outfit_calendar +(date,outfit_id,owner_id,worn_at,notes) +VALUES + +INSERT INTO outfit_calendar +(date,outfit_id,owner_id,worn_at,notes) +VALUES +('2026-07-01',1,1,'2026-07-01',''), +('2026-07-02',2,1,'2026-07-02',''), +('2026-07-03',3,1,'2026-07-03',''), +('2026-07-04',1,1,'2026-07-04',''), +('2026-07-05',4,1,'2026-07-05',''), +('2026-07-06',5,1,'2026-07-06',''), +('2026-07-07',1,1,'2026-07-07',''), +('2026-07-08',2,1,'2026-07-08',''), +('2026-07-09',3,1,'2026-07-09',''), +('2026-07-10',4,1,'2026-07-10',''), +('2026-07-11',5,1,'2026-07-11',''); + + +PRAGMA foreign_keys=ON; diff --git a/src/auth/conditional-auth.guard.ts b/src/auth/conditional-auth.guard.ts index edf1e26..2b79636 100644 --- a/src/auth/conditional-auth.guard.ts +++ b/src/auth/conditional-auth.guard.ts @@ -20,11 +20,13 @@ export class ConditionalAuthGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + if (!this.configService.get('AUTH_ENABLED')) { + request['user'] = { userId: 1 }; return true; } - const request = context.switchToHttp().getRequest(); const token = (request.cookies as Record)?.['access_token']; if (token) { try { diff --git a/src/dal/entity/outfit.entity.ts b/src/dal/entity/outfit.entity.ts index 84381e7..74955ed 100644 --- a/src/dal/entity/outfit.entity.ts +++ b/src/dal/entity/outfit.entity.ts @@ -3,11 +3,13 @@ import { Entity, ManyToMany, ManyToOne, + OneToMany, PrimaryKey, Property, type Ref, } from '@mikro-orm/core'; import { Garment } from './garment.entity'; +import { OutfitCalendar } from './outfit-calendar.entity'; import { ShareableId } from './shareableId.entity'; import { User } from './user.entity'; @@ -33,6 +35,12 @@ export class Outfit extends ShareableId { @ManyToMany(() => Garment, (garment) => garment.outfits, { owner: true }) public garments = new Collection(this); + @OneToMany( + () => OutfitCalendar, + (calendar) => calendar.outfit, + ) + public calendar = new Collection(this); + @ManyToOne({ entity: () => User, deleteRule: 'cascade', @@ -40,4 +48,4 @@ export class Outfit extends ShareableId { nullable: true, }) public owner?: Ref; -} +} \ No newline at end of file diff --git a/src/wardrobe/garment.service.ts b/src/wardrobe/garment.service.ts index e6ee028..2cf92ed 100644 --- a/src/wardrobe/garment.service.ts +++ b/src/wardrobe/garment.service.ts @@ -18,6 +18,7 @@ import { UpdateGarmentDto } from './dto/update-garment.dto'; import { SearchGarmentDto } from './dto/search-garment.dto'; import { GarmentCategory } from './garment-category.enum'; import { WardrobeShareService } from '../wardrobe-share/wardrobe-share.service'; +import { OutfitCalendar } from '../dal/entity/outfit-calendar.entity'; const CANONICAL_SIZES = [ 'XX-Small', @@ -36,14 +37,19 @@ const CANONICAL_SIZES = [ export class GarmentService { private readonly logger = new Logger(GarmentService.name); - constructor( - @InjectRepository(Garment) - private readonly garmentRepository: EntityRepository, - @InjectRepository(User) - private readonly userRepository: EntityRepository, - private readonly fileService: FileService, - private readonly shareService: WardrobeShareService, - ) {} +constructor( + @InjectRepository(Garment) + private readonly garmentRepository: EntityRepository, + + @InjectRepository(User) + private readonly userRepository: EntityRepository, + + @InjectRepository(OutfitCalendar) + private readonly calendarRepository: EntityRepository, + + private readonly fileService: FileService, + private readonly shareService: WardrobeShareService, +) {} resolveCategoryLabel(value: string, i18n: I18nContext): string { const normalized = value.toLowerCase(); @@ -75,25 +81,28 @@ export class GarmentService { : {}), }; - if (userId != null) { - if (viewOwner != null && viewOwner !== userId) { - return this.garmentRepository.find( - { owner: { id: viewOwner }, ...searchConditions }, - { populate: ['photo'], orderBy: { id: 'DESC' } }, - ); - } - return this.garmentRepository.find( - { owner: { id: userId }, ...searchConditions }, - { populate: ['photo'], orderBy: { id: 'DESC' } }, - ); - } - // AUTH_ENABLED=false: only return garments that belong to no user +if (userId != null) { + if (viewOwner != null && viewOwner !== userId) { return this.garmentRepository.find( - { owner: null, ...searchConditions }, + { owner: { id: viewOwner }, ...searchConditions }, { populate: ['photo'], orderBy: { id: 'DESC' } }, ); } + console.log("findAll userId =", userId); + + return this.garmentRepository.find( + { owner: { id: userId }, ...searchConditions }, + { populate: ['photo'], orderBy: { id: 'DESC' } }, + ); +} + +// AUTH_ENABLED=false: only return garments that belong to no user +return this.garmentRepository.find( + { owner: null, ...searchConditions }, + { populate: ['photo'], orderBy: { id: 'DESC' } }, +); + } async findOne( id: number, userId?: number, @@ -379,4 +388,33 @@ export class GarmentService { if (['xxs', '2xs', '2xsmall', 'xxsmall'].includes(s)) return 'XX-Small'; return input.trim(); } -} + + async getWearStats(userId: number) { + const garments = await this.garmentRepository.find( + { owner: { id: userId } }, + { populate: ['outfits'] }, + ); + + return Promise.all( + garments.map(async (garment) => { + let wearCount = 0; + + for (const outfit of garment.outfits) { + const worn = await this.calendarRepository.find({ + outfit: outfit.id, + owner: userId, + wornAt: { $ne: null }, + }); + + wearCount += worn.length; + } + + return { + name: garment.name, + category: garment.category, + wearCount, + }; + }), + ); +}} + diff --git a/src/wardrobe/stats.controller.ts b/src/wardrobe/stats.controller.ts new file mode 100644 index 0000000..632fe0d --- /dev/null +++ b/src/wardrobe/stats.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Render, Req } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import { GarmentService } from './garment.service'; + +@Controller('stats') +export class StatsController { + constructor( + private readonly garmentService: GarmentService, + ) {} + + @Get() + @Render('stats/index') + async index(@Req() req: FastifyRequest) { + + // temporary user ID until auth is wired in + const userId = 1; + + const stats = await this.garmentService.getWearStats(userId); + + return { + stats, + }; + } +} \ No newline at end of file diff --git a/src/wardrobe/wardrobe.controller.ts b/src/wardrobe/wardrobe.controller.ts index f328482..52f35c2 100644 --- a/src/wardrobe/wardrobe.controller.ts +++ b/src/wardrobe/wardrobe.controller.ts @@ -49,6 +49,7 @@ export class WardrobeController { @I18n() i18n: I18nContext, ) { const userId = this.userId(req); + console.log("wardrobe user id:", userId); let viewOwner: number | undefined; let sharedWardrobes: any[] = []; let canEdit = true; diff --git a/src/wardrobe/wardrobe.module.ts b/src/wardrobe/wardrobe.module.ts index ff8ffcc..cbb60d3 100644 --- a/src/wardrobe/wardrobe.module.ts +++ b/src/wardrobe/wardrobe.module.ts @@ -13,6 +13,7 @@ import { CalendarService } from './calendar.service'; import { CalendarController } from './calendar.controller'; import { WardrobeController } from './wardrobe.controller'; import { OutfitController } from './outfit.controller'; +import { StatsController } from './stats.controller'; @Module({ imports: [ @@ -21,7 +22,7 @@ import { OutfitController } from './outfit.controller'; WardrobeShareModule, MikroOrmModule.forFeature([Garment, Outfit, OutfitCalendar, User]), ], - controllers: [WardrobeController, OutfitController, CalendarController], + controllers: [WardrobeController, OutfitController, CalendarController, StatsController], providers: [GarmentService, OutfitService, CalendarService], exports: [GarmentService, OutfitService, CalendarService], }) diff --git a/views/partials/dock.hbs b/views/partials/dock.hbs index e13e89a..8cef3bc 100644 --- a/views/partials/dock.hbs +++ b/views/partials/dock.hbs @@ -28,4 +28,11 @@ {{t 'lang.CALENDAR'}} + + + + + + Stats + \ No newline at end of file diff --git a/views/stats/index.hbs b/views/stats/index.hbs new file mode 100644 index 0000000..26b334d --- /dev/null +++ b/views/stats/index.hbs @@ -0,0 +1,35 @@ +{{>navbar}} + +
+ +

+Wardrobe Statistics +

+ +
+ +{{#each stats}} + +
+ +

+{{this.name}} +

+ +

+Category: {{this.category}} +

+ +

+Worn {{this.wearCount}} times +

+ +
+ +{{/each}} + +
+ +
+ +{{>dock}} \ No newline at end of file From 4b6fa65965a8824aa19d116dfd0fa28814c0157b Mon Sep 17 00:00:00 2001 From: Henry Inman Date: Sun, 26 Jul 2026 21:01:00 -0700 Subject: [PATCH 2/4] added a test user setting --- .env | 3 ++- src/auth/conditional-auth.guard.ts | 12 ++++++++++-- src/wardrobe/stats.controller.ts | 18 +++++++++++++++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.env b/.env index 7eb8685..be44e88 100644 --- a/.env +++ b/.env @@ -1,6 +1,7 @@ APP_NAME=Libre Closet #PWA_ENABLED=true -#AUTH_ENABLED=true +AUTH_ENABLED=false +DEV_USER_ID=1 #DISABLE_REGISTRATION=true # CHANGE FOR PRODUCTION! diff --git a/src/auth/conditional-auth.guard.ts b/src/auth/conditional-auth.guard.ts index 2b79636..ea5743a 100644 --- a/src/auth/conditional-auth.guard.ts +++ b/src/auth/conditional-auth.guard.ts @@ -23,18 +23,26 @@ export class ConditionalAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); if (!this.configService.get('AUTH_ENABLED')) { - request['user'] = { userId: 1 }; + const devUserId = this.configService.get('DEV_USER_ID'); + + if (devUserId) { + request['user'] = { userId: devUserId }; + } + return true; } const token = (request.cookies as Record)?.['access_token']; + if (token) { try { const payload = await this.jwtService.verifyAsync(token, { secret: this.configService.get('ACCESS_TOKEN_SECRET'), }); + await this.authService.verifyPwf(payload); request['user'] = payload; + return true; } catch { // invalid/expired token or fingerprint mismatch — fall through to redirect @@ -45,4 +53,4 @@ export class ConditionalAuthGuard implements CanActivate { response.redirect('/auth/login', 302); return false; } -} +} \ No newline at end of file diff --git a/src/wardrobe/stats.controller.ts b/src/wardrobe/stats.controller.ts index 632fe0d..039bd92 100644 --- a/src/wardrobe/stats.controller.ts +++ b/src/wardrobe/stats.controller.ts @@ -1,19 +1,31 @@ -import { Controller, Get, Render, Req } from '@nestjs/common'; +import { Controller, Get, Render, Req, UseGuards } from '@nestjs/common'; import type { FastifyRequest } from 'fastify'; import { GarmentService } from './garment.service'; +import { ConditionalAuthGuard } from '../auth/conditional-auth.guard'; +import { Payload } from '../auth/dto/payload.dto'; +@UseGuards(ConditionalAuthGuard) @Controller('stats') export class StatsController { constructor( private readonly garmentService: GarmentService, ) {} + private userId(req: FastifyRequest): number | undefined { + return (req['user'] as Payload | undefined)?.userId; + } + @Get() @Render('stats/index') async index(@Req() req: FastifyRequest) { - // temporary user ID until auth is wired in - const userId = 1; + console.log("stats request user:", req['user']); + + const userId = this.userId(req); + + if (userId == null) { + throw new Error('No user ID found'); + } const stats = await this.garmentService.getWearStats(userId); From 1c570279cbb1ca8184351ccb735fd1eeceb88e79 Mon Sep 17 00:00:00 2001 From: Henry Inman Date: Sun, 26 Jul 2026 21:23:06 -0700 Subject: [PATCH 3/4] restored findAll() protection logic --- src/wardrobe/garment.service.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/wardrobe/garment.service.ts b/src/wardrobe/garment.service.ts index 2cf92ed..c19fccc 100644 --- a/src/wardrobe/garment.service.ts +++ b/src/wardrobe/garment.service.ts @@ -80,7 +80,6 @@ constructor( } : {}), }; - if (userId != null) { if (viewOwner != null && viewOwner !== userId) { return this.garmentRepository.find( @@ -89,8 +88,6 @@ if (userId != null) { ); } - console.log("findAll userId =", userId); - return this.garmentRepository.find( { owner: { id: userId }, ...searchConditions }, { populate: ['photo'], orderBy: { id: 'DESC' } }, From dba6ace8330af76da20a6fb1a72bd7a57eac638a Mon Sep 17 00:00:00 2001 From: Henry Inman Date: Sun, 26 Jul 2026 22:36:28 -0700 Subject: [PATCH 4/4] outfit pages broke, this fixes it --- src/wardrobe/garment.service.ts | 2 +- src/wardrobe/outfit.controller.ts | 24 +++++++++++------- src/wardrobe/outfit.service.ts | 41 +++++++++++++++++++++---------- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/wardrobe/garment.service.ts b/src/wardrobe/garment.service.ts index c19fccc..ca33813 100644 --- a/src/wardrobe/garment.service.ts +++ b/src/wardrobe/garment.service.ts @@ -94,7 +94,7 @@ if (userId != null) { ); } -// AUTH_ENABLED=false: only return garments that belong to no user +// AUTH_ENABLED=false return this.garmentRepository.find( { owner: null, ...searchConditions }, { populate: ['photo'], orderBy: { id: 'DESC' } }, diff --git a/src/wardrobe/outfit.controller.ts b/src/wardrobe/outfit.controller.ts index 971391a..f1532a1 100644 --- a/src/wardrobe/outfit.controller.ts +++ b/src/wardrobe/outfit.controller.ts @@ -131,15 +131,21 @@ export class OutfitController { return { outfit }; } - @Get(':id/edit') - @Render('outfits/form') - async editForm( - @Param('id', ParseIntPipe) id: number, - @Req() req: FastifyRequest, - @I18n() i18n: I18nContext, - @Query('returnTo') returnTo?: string, - @Query('returnToWeek') returnToWeek?: string, - ) { +@Get(':id/edit') +@Render('outfits/form') +async editForm( + @Param('id', ParseIntPipe) id: number, + @Req() req: FastifyRequest, + @I18n() i18n: I18nContext, + @Query('returnTo') returnTo?: string, + @Query('returnToWeek') returnToWeek?: string, +) { + console.log("=== OUTFIT EDIT ROUTE HIT ==="); + console.log("ID:", id); + console.log("USER:", this.userId(req)); + console.log("RAW USER:", req['user']); + + const [outfit, garments] = await Promise.all([ this.outfitService.findOne(id, this.userId(req)), this.garmentService.findAll(this.userId(req)), diff --git a/src/wardrobe/outfit.service.ts b/src/wardrobe/outfit.service.ts index 853658c..832576c 100644 --- a/src/wardrobe/outfit.service.ts +++ b/src/wardrobe/outfit.service.ts @@ -41,22 +41,37 @@ export class OutfitService { { populate: ['garments', 'garments.photo'] }, ); } +async findOne(id: number, userId: number | string) { + console.log("=== FIND ONE OUTFIT ==="); + console.log("OUTFIT ID:", id); + console.log("USER ID:", userId); - async findOne(id: number, userId?: number): Promise { - const outfit = await this.outfitRepository.findOne(id, { - populate: ['garments', 'garments.photo'], - }); - if (!outfit) throw new NotFoundException('Outfit not found'); - if (userId != null) { - // auth mode: must be the owner - if (outfit.owner?.id !== userId) throw new ForbiddenException(); - } else { - // no-auth mode: only allow ownerless outfits - if (outfit.owner != null) throw new ForbiddenException(); - } - return outfit; + const outfit = await this.outfitRepository.findOne( + { id }, + { populate: ['garments', 'garments.photo', 'owner'] }, + ); + + console.log("OUTFIT FOUND:", outfit); + + if (!outfit) { + throw new NotFoundException(); } + const ownerId = outfit.owner?.id; + + console.log("OWNER ID:", ownerId); + console.log("OWNER ID TYPE:", typeof ownerId); + console.log("USER ID TYPE:", typeof userId); + console.log("EQUAL CHECK:", ownerId === Number(userId)); + + if (ownerId !== Number(userId)) { + console.log("BLOCKED: USER DOES NOT OWN OUTFIT"); + throw new ForbiddenException(); + } + + return outfit; +} + async findOneByShareableId(shareableId: string): Promise { const outfit = await this.outfitRepository.findOne( { shareableId },