feat: member portal Sprint 11 — Memories / Gallery

- GalleryAlbum + GalleryItem models + migration (publish gate, cover image, ordered items)
- GalleryModule: admin CRUD for albums + items
- Member endpoints: GET /my/memories (published albums w/ cover + count), GET /my/memories/:id (album + photos)
- /my/memories page: album grid with cover thumbnails
- /my/memories/[id] page: photo grid with captions
- Memories nav item; register GalleryModule

Completes Phase 2 (Intelligence) of the member portal — Sprints 9-11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:10:27 +05:30
parent 5801cbf85b
commit 3e86e0ffc0
13 changed files with 423 additions and 1 deletions
+2
View File
@@ -22,6 +22,7 @@ import { GamificationModule } from './modules/gamification/gamification.module';
import { SevaModule } from './modules/seva/seva.module';
import { CirclesModule } from './modules/circles/circles.module';
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
import { GalleryModule } from './modules/gallery/gallery.module';
@Module({
imports: [
@@ -48,6 +49,7 @@ import { KnowledgeModule } from './modules/knowledge/knowledge.module';
SevaModule,
CirclesModule,
KnowledgeModule,
GalleryModule,
],
})
export class AppModule {}
@@ -0,0 +1,55 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { GalleryService } from './gallery.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context';
@Controller('admin/gallery')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN')
export class GalleryController {
constructor(private readonly service: GalleryService) {}
@Get('albums')
listAlbums(@CurrentTenantContext() ctx: TenantContext) {
return this.service.listAlbumsAdmin(ctx.tenantId);
}
@Post('albums')
createAlbum(
@CurrentTenantContext() ctx: TenantContext,
@Body() body: { title: string; description?: string; coverUrl?: string; isPublished?: boolean },
) {
return this.service.createAlbum(ctx.tenantId, ctx.adminId!, body);
}
@Patch('albums/:id')
updateAlbum(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: { title?: string; description?: string; coverUrl?: string; isPublished?: boolean },
) {
return this.service.updateAlbum(ctx.tenantId, id, body);
}
@Delete('albums/:id')
removeAlbum(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.service.removeAlbum(ctx.tenantId, id);
}
@Post('albums/:id/items')
addItem(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Body() body: { mediaUrl: string; caption?: string; sortOrder?: number },
) {
return this.service.addItem(ctx.tenantId, id, body);
}
@Delete('items/:itemId')
removeItem(@CurrentTenantContext() ctx: TenantContext, @Param('itemId') itemId: string) {
return this.service.removeItem(ctx.tenantId, itemId);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { GalleryController } from './gallery.controller';
import { GalleryService } from './gallery.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [GalleryController],
providers: [GalleryService],
exports: [GalleryService],
})
export class GalleryModule {}
@@ -0,0 +1,120 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class GalleryService {
constructor(private readonly prisma: PrismaService) {}
// ── Admin: albums ──────────────────────────────────────────────────────────
async listAlbumsAdmin(tenantId: string) {
return this.prisma.galleryAlbum.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { items: true } } },
});
}
async createAlbum(tenantId: string, adminId: string, dto: {
title: string;
description?: string;
coverUrl?: string;
isPublished?: boolean;
}) {
return this.prisma.galleryAlbum.create({
data: {
tenantId,
title: dto.title,
description: dto.description ?? null,
coverUrl: dto.coverUrl ?? null,
isPublished: dto.isPublished ?? false,
createdBy: adminId,
},
});
}
async updateAlbum(tenantId: string, id: string, dto: {
title?: string;
description?: string;
coverUrl?: string;
isPublished?: boolean;
}) {
const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } });
if (!album) throw new NotFoundException('Album not found');
return this.prisma.galleryAlbum.update({
where: { id },
data: {
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.coverUrl !== undefined && { coverUrl: dto.coverUrl }),
...(dto.isPublished !== undefined && { isPublished: dto.isPublished }),
},
});
}
async removeAlbum(tenantId: string, id: string) {
const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } });
if (!album) throw new NotFoundException('Album not found');
await this.prisma.galleryAlbum.delete({ where: { id } });
return { ok: true };
}
// ── Admin: items ─────────────────────────────────────────────────────────────
async addItem(tenantId: string, albumId: string, dto: { mediaUrl: string; caption?: string; sortOrder?: number }) {
const album = await this.prisma.galleryAlbum.findFirst({ where: { id: albumId, tenantId } });
if (!album) throw new NotFoundException('Album not found');
return this.prisma.galleryItem.create({
data: {
albumId,
tenantId,
mediaUrl: dto.mediaUrl,
caption: dto.caption ?? null,
sortOrder: dto.sortOrder ?? 0,
},
});
}
async removeItem(tenantId: string, itemId: string) {
const item = await this.prisma.galleryItem.findFirst({ where: { id: itemId, tenantId } });
if (!item) throw new NotFoundException('Item not found');
await this.prisma.galleryItem.delete({ where: { id: itemId } });
return { ok: true };
}
// ── Member: read published albums ────────────────────────────────────────────
async listForMember(tenantId: string) {
const albums = await this.prisma.galleryAlbum.findMany({
where: { tenantId, isPublished: true },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { items: true } } },
});
return albums.map((a) => ({
id: a.id,
title: a.title,
description: a.description,
coverUrl: a.coverUrl,
itemCount: a._count.items,
createdAt: a.createdAt.toISOString(),
}));
}
async getAlbumForMember(tenantId: string, albumId: string) {
const album = await this.prisma.galleryAlbum.findFirst({
where: { id: albumId, tenantId, isPublished: true },
include: { items: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] } },
});
if (!album) throw new NotFoundException('Album not found');
return {
id: album.id,
title: album.title,
description: album.description,
items: album.items.map((i) => ({
id: i.id,
mediaUrl: i.mediaUrl,
caption: i.caption,
})),
};
}
}
+12
View File
@@ -4,6 +4,7 @@ import { SevaService } from '../seva/seva.service';
import { CirclesService } from '../circles/circles.service';
import { AskAIService } from '../ask-ai/ask-ai.service';
import { KnowledgeService } from '../knowledge/knowledge.service';
import { GalleryService } from '../gallery/gallery.service';
import { MemberAuth } from '../auth/member-auth.decorator';
import { CurrentMember } from '../auth/current-member.decorator';
import type { MemberJwtPayload } from '@tower/types';
@@ -32,6 +33,7 @@ export class MyController {
private readonly circles: CirclesService,
private readonly askAI: AskAIService,
private readonly knowledge: KnowledgeService,
private readonly gallery: GalleryService,
) {}
@Get('knowledge')
@@ -39,6 +41,16 @@ export class MyController {
return this.knowledge.listForMember(member.tenantId, q);
}
@Get('memories')
memoriesList(@CurrentMember() member: MemberJwtPayload) {
return this.gallery.listForMember(member.tenantId);
}
@Get('memories/:id')
memoriesAlbum(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) {
return this.gallery.getAlbumForMember(member.tenantId, id);
}
@Get('ask')
askHistory(@CurrentMember() member: MemberJwtPayload) {
return this.askAI.history(member.sub, member.tenantId);
+2 -1
View File
@@ -7,9 +7,10 @@ import { GamificationModule } from '../gamification/gamification.module';
import { CirclesModule } from '../circles/circles.module';
import { AskAIModule } from '../ask-ai/ask-ai.module';
import { KnowledgeModule } from '../knowledge/knowledge.module';
import { GalleryModule } from '../gallery/gallery.module';
@Module({
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule],
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule, GalleryModule],
controllers: [MyController],
providers: [MyService],
})