Skip to content

Commit bd7269a

Browse files
committed
feat(api): add full playlist module with atomic updates and DB pagination
Builds out the playlist module (previously only a stub repository): - types: internal Playlist/Track types + toPlaylistResponse DTO mapper - validators: re-exports shared Zod schemas - service: CRUD + ownership checks + paginated listByUserId - controller: getPublic, listMine, getMine, create, update, delete - routes: public GET /public/:id, auth-gated CRUD at / - repository: DB-level pagination, atomic metadata+tracks update via $transaction, preventing half-updated playlist states Routes were already imported in app.ts — this wires the implementation.
1 parent a7efcd4 commit bd7269a

7 files changed

Lines changed: 419 additions & 11 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { NextFunction, Request, Response } from 'express'
2+
import { paginationSchema } from '@universal-healthcare/shared'
3+
import { AppError } from '../../../shared/errors/app-error.js'
4+
import { envelope } from '../../../shared/pagination/format.js'
5+
import { playlistService } from '../services/playlist.service.js'
6+
import {
7+
toPlaylistResponse,
8+
} from '../types/playlist.types.js'
9+
import {
10+
createPlaylistSchema,
11+
playlistIdParamSchema,
12+
updatePlaylistSchema,
13+
} from '../validators/playlist.validators.js'
14+
15+
function userIdOrThrow(req: Request): string {
16+
const id = (req as Request & { userId?: string }).userId
17+
if (!id) {
18+
throw new AppError(401, 'UNAUTHENTICATED', 'Authentication required')
19+
}
20+
return id
21+
}
22+
23+
export const playlistController = {
24+
// ── Public: get a public playlist by ID ──────────────────────────────
25+
async getPublic(
26+
req: Request,
27+
res: Response,
28+
next: NextFunction
29+
): Promise<void> {
30+
try {
31+
const { id } = playlistIdParamSchema.parse(req.params)
32+
const playlist = await playlistService.getPublicById(id)
33+
res.status(200).json({ data: toPlaylistResponse(playlist) })
34+
} catch (err) {
35+
next(err)
36+
}
37+
},
38+
39+
// ── Authenticated: list my playlists ─────────────────────────────────
40+
async listMine(
41+
req: Request,
42+
res: Response,
43+
next: NextFunction
44+
): Promise<void> {
45+
try {
46+
const me = userIdOrThrow(req)
47+
const { page, pageSize } = paginationSchema.parse(req.query)
48+
const { items, total } = await playlistService.listByUserId(
49+
me,
50+
page,
51+
pageSize
52+
)
53+
res.status(200).json({
54+
data: items.map(toPlaylistResponse),
55+
pagination: envelope(page, pageSize, total),
56+
})
57+
} catch (err) {
58+
next(err)
59+
}
60+
},
61+
62+
// ── Authenticated: get one of my playlists ───────────────────────────
63+
async getMine(
64+
req: Request,
65+
res: Response,
66+
next: NextFunction
67+
): Promise<void> {
68+
try {
69+
const me = userIdOrThrow(req)
70+
const { id } = playlistIdParamSchema.parse(req.params)
71+
const playlist = await playlistService.getById(id, me)
72+
res.status(200).json({ data: toPlaylistResponse(playlist) })
73+
} catch (err) {
74+
next(err)
75+
}
76+
},
77+
78+
// ── Authenticated: create a playlist ─────────────────────────────────
79+
async create(
80+
req: Request,
81+
res: Response,
82+
next: NextFunction
83+
): Promise<void> {
84+
try {
85+
const me = userIdOrThrow(req)
86+
const body = createPlaylistSchema.parse(req.body)
87+
const playlist = await playlistService.create({
88+
userId: me,
89+
title: body.title,
90+
isPublic: body.isPublic,
91+
tracks: body.tracks,
92+
})
93+
res.status(201).json({ data: toPlaylistResponse(playlist) })
94+
} catch (err) {
95+
next(err)
96+
}
97+
},
98+
99+
// ── Authenticated: update a playlist ─────────────────────────────────
100+
async update(
101+
req: Request,
102+
res: Response,
103+
next: NextFunction
104+
): Promise<void> {
105+
try {
106+
const me = userIdOrThrow(req)
107+
const { id } = playlistIdParamSchema.parse(req.params)
108+
const body = updatePlaylistSchema.parse(req.body)
109+
const playlist = await playlistService.update(id, me, body)
110+
res.status(200).json({ data: toPlaylistResponse(playlist) })
111+
} catch (err) {
112+
next(err)
113+
}
114+
},
115+
116+
// ── Authenticated: delete a playlist ─────────────────────────────────
117+
async delete(
118+
req: Request,
119+
res: Response,
120+
next: NextFunction
121+
): Promise<void> {
122+
try {
123+
const me = userIdOrThrow(req)
124+
const { id } = playlistIdParamSchema.parse(req.params)
125+
await playlistService.delete(id, me)
126+
// 204 No Content — matches follow.controller.ts:41 / comment.controller.ts:101.
127+
res.status(204).end()
128+
} catch (err) {
129+
next(err)
130+
}
131+
},
132+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// ─────────────────────────────────────────────────────────────────────────────
2+
// Public surface of the playlists module.
3+
// app.ts imports `playlistsRouter`; tests may import individual layers.
4+
// ─────────────────────────────────────────────────────────────────────────────
5+
6+
export { playlistsRouter } from './routes/playlist.routes.js'

apps/api/src/modules/playlists/repositories/playlist.repository.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,22 @@ function playlistFromPrisma(raw: RawPlaylist): Playlist {
4444
}
4545

4646
export const playlistRepository = {
47-
async listByUserId(userId: string): Promise<Playlist[]> {
48-
const rows = await prisma.playlist.findMany({
49-
where: { userId },
50-
orderBy: { createdAt: 'desc' },
51-
include: { tracks: { orderBy: { position: 'asc' } } },
52-
})
53-
return rows.map(playlistFromPrisma)
47+
async listByUserId(
48+
userId: string,
49+
skip: number,
50+
take: number
51+
): Promise<{ items: Playlist[]; total: number }> {
52+
const [rows, total] = await Promise.all([
53+
prisma.playlist.findMany({
54+
where: { userId },
55+
orderBy: { createdAt: 'desc' },
56+
include: { tracks: { orderBy: { position: 'asc' } } },
57+
skip,
58+
take,
59+
}),
60+
prisma.playlist.count({ where: { userId } }),
61+
])
62+
return { items: rows.map(playlistFromPrisma), total }
5463
},
5564

5665
async findById(id: string): Promise<Playlist | null> {
@@ -90,15 +99,53 @@ export const playlistRepository = {
9099
])
91100
},
92101

93-
async updateMetadata(
102+
async update(
94103
id: string,
95-
input: Omit<UpdatePlaylistInput, 'tracks'>
104+
input: UpdatePlaylistInput
96105
): Promise<Playlist> {
97-
const row = await prisma.playlist.update({
106+
const { tracks, ...metadata } = input
107+
const hasMetadata =
108+
metadata.title !== undefined || metadata.isPublic !== undefined
109+
const hasTracks = tracks !== undefined
110+
111+
// When both metadata and tracks change, wrap in a transaction so the
112+
// playlist is never half-updated.
113+
if (hasMetadata && hasTracks) {
114+
const row = await prisma.$transaction(async (tx) => {
115+
await tx.track.deleteMany({ where: { playlistId: id } })
116+
await tx.track.createMany({
117+
data: tracks.map((t, position) => ({ ...t, playlistId: id, position })),
118+
})
119+
return tx.playlist.update({
120+
where: { id },
121+
data: metadata as { title?: string; isPublic?: boolean },
122+
include: { tracks: { orderBy: { position: 'asc' } } },
123+
})
124+
})
125+
return playlistFromPrisma(row)
126+
}
127+
128+
if (hasTracks) {
129+
await this.setTracks(id, tracks)
130+
}
131+
132+
if (hasMetadata) {
133+
const row = await prisma.playlist.update({
134+
where: { id },
135+
data: metadata as { title?: string; isPublic?: boolean },
136+
include: { tracks: { orderBy: { position: 'asc' } } },
137+
})
138+
return playlistFromPrisma(row)
139+
}
140+
141+
// Re-fetch — tracks-only update needs fresh state with new positions.
142+
const row = await prisma.playlist.findUnique({
98143
where: { id },
99-
data: input,
100144
include: { tracks: { orderBy: { position: 'asc' } } },
101145
})
146+
if (!row) {
147+
throw new Error(`Playlist ${id} vanished during update`)
148+
}
102149
return playlistFromPrisma(row)
103150
},
104151

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Router } from 'express'
2+
import { requireAuth } from '../../../shared/middleware/auth.middleware.js'
3+
import { playlistController } from '../controllers/playlist.controller.js'
4+
5+
// Mounted at `/api/playlists` in app.ts. Routes here are RELATIVE to that
6+
// mount point — `/api/playlists/public/:id` → `GET /api/playlists/public/:id`.
7+
export const playlistsRouter: Router = Router()
8+
9+
// ─────────────────────────────────────────────────────────────────────────────
10+
// Public endpoint — anonymous callers can view public playlists.
11+
// Must precede the requireAuth gate.
12+
// ─────────────────────────────────────────────────────────────────────────────
13+
14+
playlistsRouter.get('/public/:id', (req, res, next) => {
15+
playlistController.getPublic(req, res, next).catch(next)
16+
})
17+
18+
// ─────────────────────────────────────────────────────────────────────────────
19+
// Authenticated endpoints.
20+
// ─────────────────────────────────────────────────────────────────────────────
21+
22+
playlistsRouter.use(requireAuth)
23+
24+
playlistsRouter.get('/', (req, res, next) => {
25+
playlistController.listMine(req, res, next).catch(next)
26+
})
27+
28+
playlistsRouter.get('/:id', (req, res, next) => {
29+
playlistController.getMine(req, res, next).catch(next)
30+
})
31+
32+
playlistsRouter.post('/', (req, res, next) => {
33+
playlistController.create(req, res, next).catch(next)
34+
})
35+
36+
playlistsRouter.put('/:id', (req, res, next) => {
37+
playlistController.update(req, res, next).catch(next)
38+
})
39+
40+
playlistsRouter.delete('/:id', (req, res, next) => {
41+
playlistController.delete(req, res, next).catch(next)
42+
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { AppError } from '../../../shared/errors/app-error.js'
2+
import { playlistRepository } from '../repositories/playlist.repository.js'
3+
import type { Playlist } from '../types/playlist.types.js'
4+
5+
export interface PlaylistListResult {
6+
items: Playlist[]
7+
total: number
8+
page: number
9+
pageSize: number
10+
}
11+
12+
export const playlistService = {
13+
async listByUserId(
14+
userId: string,
15+
page: number,
16+
pageSize: number
17+
): Promise<{ items: Playlist[]; total: number }> {
18+
const skip = (page - 1) * pageSize
19+
return playlistRepository.listByUserId(userId, skip, pageSize)
20+
},
21+
22+
async getPublicById(id: string): Promise<Playlist> {
23+
const playlist = await playlistRepository.findById(id)
24+
// 404 when the playlist is private or missing — don't leak which
25+
// playlist IDs exist. Same rationale as `commentService.listForPlaylist`.
26+
if (!playlist || !playlist.isPublic) {
27+
throw new AppError(
28+
404,
29+
'PLAYLIST_NOT_FOUND',
30+
'Playlist not found'
31+
)
32+
}
33+
return playlist
34+
},
35+
36+
async getById(id: string, requestingUserId: string): Promise<Playlist> {
37+
const playlist = await playlistRepository.findById(id)
38+
if (!playlist) {
39+
throw new AppError(404, 'PLAYLIST_NOT_FOUND', 'Playlist not found')
40+
}
41+
// Owner can view their own private playlists.
42+
if (!playlist.isPublic && playlist.userId !== requestingUserId) {
43+
throw new AppError(
44+
404,
45+
'PLAYLIST_NOT_FOUND',
46+
'Playlist not found'
47+
)
48+
}
49+
return playlist
50+
},
51+
52+
async create(
53+
input: {
54+
userId: string
55+
title: string
56+
isPublic: boolean
57+
tracks: Array<{ title: string; artist: string; duration: number }>
58+
}
59+
): Promise<Playlist> {
60+
return playlistRepository.create(input)
61+
},
62+
63+
async update(
64+
id: string,
65+
requestingUserId: string,
66+
input: {
67+
title?: string
68+
isPublic?: boolean
69+
tracks?: Array<{ title: string; artist: string; duration: number }>
70+
}
71+
): Promise<Playlist> {
72+
const existing = await playlistRepository.findById(id)
73+
if (!existing) {
74+
throw new AppError(404, 'PLAYLIST_NOT_FOUND', 'Playlist not found')
75+
}
76+
if (existing.userId !== requestingUserId) {
77+
throw new AppError(
78+
403,
79+
'FORBIDDEN',
80+
'You can only update your own playlists'
81+
)
82+
}
83+
84+
return playlistRepository.update(id, input)
85+
},
86+
87+
async delete(id: string, requestingUserId: string): Promise<void> {
88+
const existing = await playlistRepository.findById(id)
89+
if (!existing) {
90+
throw new AppError(404, 'PLAYLIST_NOT_FOUND', 'Playlist not found')
91+
}
92+
if (existing.userId !== requestingUserId) {
93+
throw new AppError(
94+
403,
95+
'FORBIDDEN',
96+
'You can only delete your own playlists'
97+
)
98+
}
99+
await playlistRepository.delete(id)
100+
},
101+
}

0 commit comments

Comments
 (0)