Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -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!
Expand Down
68 changes: 68 additions & 0 deletions seed-stats.sql
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 12 additions & 2 deletions src/auth/conditional-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,29 @@ export class ConditionalAuthGuard implements CanActivate {
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();

if (!this.configService.get<boolean>('AUTH_ENABLED')) {
const devUserId = this.configService.get<number>('DEV_USER_ID');

if (devUserId) {
request['user'] = { userId: devUserId };
}

return true;
}

const request = context.switchToHttp().getRequest<FastifyRequest>();
const token = (request.cookies as Record<string, string>)?.['access_token'];

if (token) {
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('ACCESS_TOKEN_SECRET'),
});

await this.authService.verifyPwf(payload);
request['user'] = payload;

return true;
} catch {
// invalid/expired token or fingerprint mismatch — fall through to redirect
Expand All @@ -43,4 +53,4 @@ export class ConditionalAuthGuard implements CanActivate {
response.redirect('/auth/login', 302);
return false;
}
}
}
10 changes: 9 additions & 1 deletion src/dal/entity/outfit.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -33,11 +35,17 @@ export class Outfit extends ShareableId {
@ManyToMany(() => Garment, (garment) => garment.outfits, { owner: true })
public garments = new Collection<Garment>(this);

@OneToMany(
() => OutfitCalendar,
(calendar) => calendar.outfit,
)
public calendar = new Collection<OutfitCalendar>(this);

@ManyToOne({
entity: () => User,
deleteRule: 'cascade',
ref: true,
nullable: true,
})
public owner?: Ref<User>;
}
}
83 changes: 59 additions & 24 deletions src/wardrobe/garment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -36,14 +37,19 @@ const CANONICAL_SIZES = [
export class GarmentService {
private readonly logger = new Logger(GarmentService.name);

constructor(
@InjectRepository(Garment)
private readonly garmentRepository: EntityRepository<Garment>,
@InjectRepository(User)
private readonly userRepository: EntityRepository<User>,
private readonly fileService: FileService,
private readonly shareService: WardrobeShareService,
) {}
constructor(
@InjectRepository(Garment)
private readonly garmentRepository: EntityRepository<Garment>,

@InjectRepository(User)
private readonly userRepository: EntityRepository<User>,

@InjectRepository(OutfitCalendar)
private readonly calendarRepository: EntityRepository<OutfitCalendar>,

private readonly fileService: FileService,
private readonly shareService: WardrobeShareService,
) {}

resolveCategoryLabel(value: string, i18n: I18nContext): string {
const normalized = value.toLowerCase();
Expand Down Expand Up @@ -74,26 +80,26 @@ 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' } },
);
}

return this.garmentRepository.find(
{ owner: { id: userId }, ...searchConditions },
{ populate: ['photo'], orderBy: { id: 'DESC' } },
);
}

// AUTH_ENABLED=false
return this.garmentRepository.find(
{ owner: null, ...searchConditions },
{ populate: ['photo'], orderBy: { id: 'DESC' } },
);
}
async findOne(
id: number,
userId?: number,
Expand Down Expand Up @@ -379,4 +385,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,
};
}),
);
}}

24 changes: 15 additions & 9 deletions src/wardrobe/outfit.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
41 changes: 28 additions & 13 deletions src/wardrobe/outfit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Outfit> {
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<Outfit> {
const outfit = await this.outfitRepository.findOne(
{ shareableId },
Expand Down
36 changes: 36 additions & 0 deletions src/wardrobe/stats.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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) {

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);

return {
stats,
};
}
}
1 change: 1 addition & 0 deletions src/wardrobe/wardrobe.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading