diff --git a/src/app.ts b/src/app.ts index ca9f6456..9ae4f930 100644 --- a/src/app.ts +++ b/src/app.ts @@ -199,6 +199,7 @@ import { CreateAnnouncementUseCase, UpdateAnnouncementUseCase, DeleteAnnouncementUseCase, + ListPinnedAnnouncementsUseCase, } from '@/modules/announcement/application/use-cases/announcement.use-cases'; import { ListAnnouncementsUseCase } from '@/modules/announcement/application/use-cases/list-announcements.use-case'; import { AnnouncementController } from '@/modules/announcement/presentation/v1/announcement.controller'; @@ -217,6 +218,11 @@ import { } from '@/modules/language/presentation/v1/language.routes'; import { CategoryRepositoryImpl } from '@/modules/category/infrastructure/category.repository.impl'; import { ListCategoriesUseCase } from '@/modules/category/application/use-cases/list-categories.use-case'; +import { + CreateCategoryUseCase, + UpdateCategoryUseCase, + DeleteCategoryUseCase, +} from '@/modules/category/application/use-cases/category.use-cases'; import { CategoryController } from '@/modules/category/presentation/v1/category.controller'; import { createCategoryRoutes } from '@/modules/category/presentation/v1/category.routes'; import { AuditLogRepositoryImpl } from '@/modules/audit/infrastructure/audit-log.repository.impl'; @@ -761,14 +767,19 @@ const announcementRepo = new AnnouncementRepositoryImpl(db); const announcementController = new AnnouncementController( new CreateAnnouncementUseCase(announcementRepo, activityEvents, auditRepo), new ListAnnouncementsUseCase(announcementRepo), + new ListPinnedAnnouncementsUseCase(announcementRepo), (cmd) => new UpdateAnnouncementUseCase(announcementRepo, activityEvents, auditRepo).execute(cmd), new DeleteAnnouncementUseCase(announcementRepo, activityEvents, auditRepo), announcementRepo, ); +const categoryRepo = new CategoryRepositoryImpl(db); const categoryController = new CategoryController({ - listCategories: new ListCategoriesUseCase(new CategoryRepositoryImpl(db)), + listCategories: new ListCategoriesUseCase(categoryRepo), + createCategory: new CreateCategoryUseCase(categoryRepo, auditRepo), + updateCategory: new UpdateCategoryUseCase(categoryRepo, auditRepo), + deleteCategory: new DeleteCategoryUseCase(categoryRepo, auditRepo), }); // ---- Modul vote (08-api-upvote-downvote.md) - upvote/downvote polymorphic @@ -1298,7 +1309,7 @@ app.route('/api/v1/announcements', createPublicAnnouncementRoutes({ controller: // Data referensi form admin app.route('/api/v1/languages', createLanguageRoutes({ controller: languageController })); app.route('/api/v1/dialects', createDialectRoutes({ controller: languageController })); -app.route('/api/v1/categories', createCategoryRoutes({ controller: categoryController })); +app.route('/api/v1/categories', createCategoryRoutes({ controller: categoryController, authenticate })); // Audit log - hanya admin & root (Section 21) const auditController = new AuditController({ listAuditLogs: new ListAuditLogsUseCase(auditRepo) }); diff --git a/src/modules/activity/__tests__/e2e/v1/activity.e2e.test.ts b/src/modules/activity/__tests__/e2e/v1/activity.e2e.test.ts index 717a8027..d3bd339c 100644 --- a/src/modules/activity/__tests__/e2e/v1/activity.e2e.test.ts +++ b/src/modules/activity/__tests__/e2e/v1/activity.e2e.test.ts @@ -265,6 +265,9 @@ describe.skipIf(!hasTestDb)('Activity feed E2E - GET /api/v1/activity (37)', () const verification = body.data.find((i) => i.kind === 'verification'); expect(verification).toBeTruthy(); expect(verification!.body).toBe('Memverifikasi kata'); + // #132: subtitle verifikasi wajib null - 'Verifikasi' duplikat kindLabel + // mobile sehingga meta dirender "Verifikasi · Verifikasi". + expect(verification!.subtitle).toBeNull(); // #99: arah vote terpisah di wire const up = body.data.find((i) => i.kind === 'vote_up'); @@ -281,6 +284,43 @@ describe.skipIf(!hasTestDb)('Activity feed E2E - GET /api/v1/activity (37)', () expect(welcome!.actor?.username).toBeTruthy(); }); + it('announcement di wire feed membawa body_type (#64) - serializer ganda toWire', async () => { + // Serializer announcement ada 2 (serialize announcement.controller + toWire + // activity.controller). Unit test repo assert domain, bukan wire - celah + // inilah yang dulu melewatkan body_type hilang. Assert di batas HTTP. + const { getTestDb } = await import('@/shared/database/drizzle/test-client'); + const { activityEvents } = await import('@/shared/database/drizzle/schema'); + const db = getTestDb(); + const stamp = Date.now(); + const annId = ulid26(`01E2EANNW${stamp}`); + await db.insert(activityEvents).values({ + kind: 'announcement', + actorId: ANONIM_USER_ID, + targetWordId: null, + targetId: annId, + occurredAt: new Date(), + dedupeKey: `announcement:${annId}`, + payload: JSON.stringify({ + title: 'Wire body_type', + body: '**Penting** dari console', + bodyType: 'md', + }), + }); + + const feed = await ( + await get('/api/v1/activity?limit=50') + ).json(); + const row = feed.data.find( + (i: { kind: string; announcement?: { id: string } | null }) => + i.kind === 'announcement' && i.announcement?.id === annId, + ); + expect(row).toBeTruthy(); + // Inti #64: body_type sampai ke wire (dulu hilang -> mobile parse plain). + expect(row.announcement.body_type).toBe('md'); + expect(row.announcement.body).toBe('**Penting** dari console'); + expect(row.announcement.title).toBe('Wire body_type'); + }); + it('welcome diurut dari waktu verifikasi, bukan waktu daftar (#47)', async () => { const { getTestDb } = await import('@/shared/database/drizzle/test-client'); const { users: usersTable } = await import('@/shared/database/drizzle/schema'); diff --git a/src/modules/activity/__tests__/unit/announcement-feed.test.ts b/src/modules/activity/__tests__/unit/announcement-feed.test.ts new file mode 100644 index 00000000..a6bd04eb --- /dev/null +++ b/src/modules/activity/__tests__/unit/announcement-feed.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +// buildItem private; feed repo diuji lewat listFeed end-to-end shape dengan +// db mock minimal. Fokus #124: announcement di wire. +import { ActivityEventFeedRepositoryImpl } from '../../infrastructure/activity-event-feed.repository.impl'; +import type { AppDatabase } from '@/shared/database/drizzle/client'; + +function makeDb() { + return {} as unknown as AppDatabase; +} + +describe('#124 announcement feed', () => { + it('body announcement = title human-readable, bukan JSON beku', async () => { + const repo = new ActivityEventFeedRepositoryImpl(makeDb()); + // Panggil private buildItem via any (test-only). + const row = { + kind: 'announcement', + id: 'evt1', + occurredAt: new Date('2026-10-08T10:00:00.000Z'), + payload: JSON.stringify({ + title: 'Maintenance singkat', + body: 'Server istirahat 10 menit.', + actionUrl: 'https://example.com/x', + actionLabel: 'Buka', + }), + targetId: 'ann1', + // kolom JOIN lain kosong + }; + const item = await ( + repo as unknown as { buildItem: (r: unknown) => Record } + ).buildItem(row); + expect(item['body']).toBe('Maintenance singkat'); + expect(String(item['body'])).not.toContain('{"title"'); + expect(item['announcement']).toMatchObject({ + title: 'Maintenance singkat', + body: 'Server istirahat 10 menit.', + actionUrl: 'https://example.com/x', + }); + }); + + it('payload announcement rusak (bukan JSON) = fallback title Pengumuman', async () => { + const repo = new ActivityEventFeedRepositoryImpl(makeDb()); + const row = { + kind: 'announcement', + id: 'evt2', + occurredAt: new Date('2026-10-08T10:00:00.000Z'), + payload: 'bukan json', + targetId: 'ann2', + }; + const item = await ( + repo as unknown as { buildItem: (r: unknown) => Record } + ).buildItem(row); + expect(item['body']).toBe('Pengumuman'); + expect(item['announcement']).toMatchObject({ title: 'Pengumuman' }); + }); +}); + + +describe('#124 body_type', () => { + it('payload bodyType valid -> wire announcement.bodyType', async () => { + const repo = new ActivityEventFeedRepositoryImpl(makeDb()); + const row = { + kind: 'announcement', + id: 'evt3', + occurredAt: new Date('2026-10-08T10:00:00.000Z'), + payload: JSON.stringify({ + title: 'Artikel profil', + body: '

Halo

', + bodyType: 'html', + }), + targetId: 'ann3', + }; + const item = await ( + repo as unknown as { buildItem: (r: unknown) => Record } + ).buildItem(row); + expect((item['announcement'] as Record)['bodyType']).toBe('html'); + }); + + it('payload tanpa bodyType -> default plain', async () => { + const repo = new ActivityEventFeedRepositoryImpl(makeDb()); + const row = { + kind: 'announcement', + id: 'evt4', + occurredAt: new Date('2026-10-08T10:00:00.000Z'), + payload: JSON.stringify({ title: 'T', body: 'Isi' }), + targetId: 'ann4', + }; + const item = await ( + repo as unknown as { buildItem: (r: unknown) => Record } + ).buildItem(row); + expect((item['announcement'] as Record)['bodyType']).toBe('plain'); + }); +}); diff --git a/src/modules/activity/domain/entities/activity-item.entity.ts b/src/modules/activity/domain/entities/activity-item.entity.ts index 170c1aad..a918ddd6 100644 --- a/src/modules/activity/domain/entities/activity-item.entity.ts +++ b/src/modules/activity/domain/entities/activity-item.entity.ts @@ -43,6 +43,8 @@ export interface ActivityItem { id: string; title: string; body: string; + /** #124 lanjutan: plain | html | md | webview (default plain). */ + bodyType: 'plain' | 'html' | 'md' | 'webview'; actionUrl: string | null; actionLabel: string | null; expired: boolean; diff --git a/src/modules/activity/infrastructure/activity-event-feed.repository.impl.ts b/src/modules/activity/infrastructure/activity-event-feed.repository.impl.ts index ebeffbec..f75e02f7 100644 --- a/src/modules/activity/infrastructure/activity-event-feed.repository.impl.ts +++ b/src/modules/activity/infrastructure/activity-event-feed.repository.impl.ts @@ -427,12 +427,17 @@ export class ActivityEventFeedRepositoryImpl { // placeholder {lemma} (kata belum tentu published saat verify) - // disubstitusi read-time dengan lemma JOIN. const frozen = row.payload?.trim() ? row.payload.trim() : null; + // #124: payload announcement = JSON {title,...} - bukan copy human. + // Body wire announcement diisi setelah parse di bawah. + const isAnnouncement = kind === 'announcement'; const body = - frozen !== null - ? lemma - ? frozen.replaceAll('{lemma}', lemma) - : frozen.replaceAll(' {lemma}', '').replace('{lemma}', '') - : this.bodyFor(kind, lemma); + isAnnouncement + ? '' + : frozen !== null + ? lemma + ? frozen.replaceAll('{lemma}', lemma) + : frozen.replaceAll(' {lemma}', '').replace('{lemma}', '') + : this.bodyFor(kind, lemma); const subtitle = this.subtitleFor(kind, lemma); // #99: arah vote dari copy beku (#94) - event kind DB tetap vote_word/ // vote_comment, split cuma di wire supaya feed home variatif tanpa @@ -449,10 +454,14 @@ export class ActivityEventFeedRepositoryImpl { // actionLabel, expiresAt}. Kadaluarsa = tetap tayang tapi expired=true // (mobile menandai & menonaktifkan action) supaya feed tak bergeser. let announcement: ActivityItem['announcement'] = null; + // #124: body wire announcement = title human-readable (bukan JSON beku). + let announcementTitle = 'Pengumuman'; + let announcementBody: string | null = null; if (kind === 'announcement') { let parsed: { title?: unknown; body?: unknown; + bodyType?: unknown; actionUrl?: unknown; actionLabel?: unknown; expiresAt?: unknown; @@ -462,10 +471,21 @@ export class ActivityEventFeedRepositoryImpl { } catch { parsed = null; } + if (typeof parsed?.title === 'string' && parsed.title.trim()) { + announcementTitle = parsed.title.trim(); + } + if (typeof parsed?.body === 'string' && parsed.body.trim()) { + announcementBody = parsed.body; + } + const bodyTypes = ['plain', 'html', 'md', 'webview'] as const; + const bodyType = bodyTypes.includes(parsed?.bodyType as (typeof bodyTypes)[number]) + ? (parsed!.bodyType as (typeof bodyTypes)[number]) + : 'plain'; announcement = { id: row.targetId ?? row.id, - title: typeof parsed?.title === 'string' ? parsed.title : 'Pengumuman', - body: typeof parsed?.body === 'string' ? parsed.body : body, + title: announcementTitle, + body: announcementBody ?? announcementTitle, + bodyType, actionUrl: typeof parsed?.actionUrl === 'string' ? parsed.actionUrl : null, actionLabel: typeof parsed?.actionLabel === 'string' ? parsed.actionLabel : null, expired: @@ -478,7 +498,8 @@ export class ActivityEventFeedRepositoryImpl { kind: wire as ActivityKind, createdAt: row.occurredAt, actor, - body, + // #124: announcement pakai title human-readable; kind lain copy beku. + body: isAnnouncement ? announcementTitle : body, subtitle, target, announcement, @@ -536,9 +557,12 @@ export class ActivityEventFeedRepositoryImpl { switch (kind) { case 'word_created': return 'Baru ditambahkan'; + // #132: subtitle 'Verifikasi' duplikat kindLabel mobile ('Verifikasi') + // sehingga meta jadi "Verifikasi · Verifikasi". Selfapply pun salah + // kategori (kindLabel 'Usulan' + subtitle 'Verifikasi'). Keduanya null. case 'word_verified': case 'suggestion_selfapply': - return 'Verifikasi'; + return null; case 'suggestion_created': return 'Usulan baru'; case 'contribution_submitted': diff --git a/src/modules/activity/presentation/v1/activity.controller.ts b/src/modules/activity/presentation/v1/activity.controller.ts index 44021453..c520ea9c 100644 --- a/src/modules/activity/presentation/v1/activity.controller.ts +++ b/src/modules/activity/presentation/v1/activity.controller.ts @@ -27,6 +27,9 @@ function toWire(item: ActivityItem) { id: a.id, title: a.title, body: a.body, + // #64: body_type wajib di wire - tanpa ini parser mobile + // jatuh plain diam-diam, md/webview tak render di jalur feed. + body_type: a.bodyType, action_url: a.actionUrl, action_label: a.actionLabel, expired: a.expired, diff --git a/src/modules/announcement/__tests__/e2e/announcement.e2e.test.ts b/src/modules/announcement/__tests__/e2e/announcement.e2e.test.ts index 4730db7d..a40fd7e2 100644 --- a/src/modules/announcement/__tests__/e2e/announcement.e2e.test.ts +++ b/src/modules/announcement/__tests__/e2e/announcement.e2e.test.ts @@ -82,7 +82,7 @@ describe.skipIf(!hasTestDb)('Announcement E2E - pengumuman admin tayang di feed expect(res.status).toBe(403); }); - it('validasi: action_label tanpa action_url ditolak; host tidak diizinkan ditolak', async () => { + it('validasi: action_label tanpa action_url ditolak; non-https ditolak; host eksternal kini diizinkan (#124)', async () => { const noUrl = await post( '/api/v1/admin/announcements', { title: 'Judul', body: 'Isi', action_label: 'Buka' }, @@ -90,12 +90,86 @@ describe.skipIf(!hasTestDb)('Announcement E2E - pengumuman admin tayang di feed ); expect(noUrl.status).toBe(400); - const badHost = await post( + const notHttps = await post( '/api/v1/admin/announcements', - { title: 'Judul', body: 'Isi', action_url: 'https://evil.example.com/x' }, + { title: 'Judul', body: 'Isi', action_url: 'http://example.com/x' }, adminToken, ); - expect(badHost.status).toBe(400); + expect(notHttps.status).toBe(400); + + // #124: host bebas (https) - dibedakan deep link/eksternal di client. + // Dibuat lalu dihapus: expired tetap tayang di feed (#102), jadi bersihkan + // via delete supaya tak ganggu test dedupe di bawah. + const externalHost = await post( + '/api/v1/admin/announcements', + { title: 'Judul', body: 'Isi', action_url: 'https://example.com/x' }, + adminToken, + ); + expect(externalHost.status).toBe(200); + const externalAnn = (await externalHost.json()).data; + await del(`/api/v1/admin/announcements/${externalAnn.id}`, adminToken); + + // #124 lanjutan: body_type enum valid; default plain; invalid 400. + const md = await post( + '/api/v1/admin/announcements', + { title: 'Judul', body: '# Isi', body_type: 'md' }, + adminToken, + ); + expect(md.status).toBe(200); + const mdData = (await md.json()).data; + expect(mdData.body_type).toBe('md'); + await del(`/api/v1/admin/announcements/${mdData.id}`, adminToken); + + const badType = await post( + '/api/v1/admin/announcements', + { title: 'Judul', body: 'Isi', body_type: 'pdf' }, + adminToken, + ); + expect(badType.status).toBe(400); + + const def = await post( + '/api/v1/admin/announcements', + { title: 'Judul', body: 'Isi' }, + adminToken, + ); + expect(def.status).toBe(200); + const defData = (await def.json()).data; + expect(defData.body_type).toBe('plain'); + await del(`/api/v1/admin/announcements/${defData.id}`, adminToken); + }); + + it('#124 webview: body URL tersimpan utuh + tayang di feed & pinned dengan body_type webview', async () => { + const created = await post( + '/api/v1/admin/announcements', + { + title: 'Webview kamu', + body: 'https://www.sambasku.com', + body_type: 'webview', + pinned_at: Math.floor(Date.now() / 1000), + }, + adminToken, + ); + expect(created.status).toBe(200); + const ann = (await created.json()).data; + expect(ann.body_type).toBe('webview'); + expect(ann.body).toBe('https://www.sambasku.com'); + + // Feed wire: body_type webview sampai ke mobile (#64 - serializer toWire). + const feed = await get('/api/v1/activity?limit=20'); + const items = (await feed.json()).data; + const tile = items.find( + (i: { announcement?: { id: string } | null }) => i.announcement?.id === ann.id, + ); + expect(tile?.announcement?.body_type).toBe('webview'); + expect(tile?.announcement?.body).toBe('https://www.sambasku.com'); + + // /pinned juga bawa webview (serializer serialize). + const pinned = await get('/api/v1/announcements/pinned'); + const pinnedItems = (await pinned.json()).data; + const pinnedTile = pinnedItems.find((p: { id: string }) => p.id === ann.id); + expect(pinnedTile?.body_type).toBe('webview'); + + await del(`/api/v1/admin/announcements/${ann.id}`, adminToken); }); it('create → tayang di feed publik dengan payload announcement; edit refresh copy; delete hilang', async () => { @@ -132,8 +206,7 @@ describe.skipIf(!hasTestDb)('Announcement E2E - pengumuman admin tayang di feed { title: 'Kamus baru rilis (revisi)' }, adminToken, ); - expect(edited.status).toBe(200); - const feed2 = await get('/api/v1/activity?limit=20'); + expect(edited.status).toBe(200); const feed2 = await get('/api/v1/activity?limit=20'); const items2 = (await feed2.json()).data; const tile2 = items2.find((i: { kind: string }) => i.kind === 'announcement'); expect(tile2.announcement.title).toBe('Kamus baru rilis (revisi)'); @@ -167,6 +240,49 @@ describe.skipIf(!hasTestDb)('Announcement E2E - pengumuman admin tayang di feed expect(tile.announcement.expired).toBe(true); }); + it('pin/unpin: PATCH pinned_at tersimpan (tidak dibuang validator) + GET /pinned', async () => { + const created = await post( + '/api/v1/admin/announcements', + { title: 'Pinned e2e', body: 'Coba pin dari console.' }, + adminToken, + ); + const { id, pinned_at: initialPin } = (await created.json()).data; + + // Awal: belum dipin (response create + list kosong) + expect(initialPin ?? null).toBeNull(); + const empty = await get('/api/v1/announcements/pinned'); + expect((await empty.json()).data).toHaveLength(0); + + // Pin lewat PATCH (jalur console) + const pinned = await patch( + `/api/v1/admin/announcements/${id}`, + { pinned_at: Math.floor(Date.now() / 1000) }, + adminToken, + ); + expect(pinned.status).toBe(200); + const pinnedBody = (await pinned.json()).data; + expect(pinnedBody.pinned_at).toBeGreaterThan(0); + + // GET /pinned publik menampilkan item + const listed = await get('/api/v1/announcements/pinned'); + const listItems = (await listed.json()).data; + expect(listItems.some((i: { id: string }) => i.id === id)).toBe(true); + + // Admin list juga menandai pin + const adminList = await get('/api/v1/admin/announcements?limit=50', adminToken); + const adminItem = (await adminList.json()) + .data.items.find((i: { id: string }) => i.id === id); + expect(adminItem.pinned_at).toBeGreaterThan(0); + + // Unpin → hilang dari /pinned + const unpinned = await patch(`/api/v1/admin/announcements/${id}`, { pinned_at: null }, adminToken); + expect((await unpinned.json()).data.pinned_at).toBeNull(); + const after = await get('/api/v1/announcements/pinned'); + expect((await after.json()).data.some((i: { id: string }) => i.id === id)).toBe(false); + + await del(`/api/v1/admin/announcements/${id}`, adminToken); + }); + it('publik GET /api/v1/announcements/:id - tamu bisa baca; soft delete 404; expired=true', async () => { // Tamu (tanpa token) baca detail by id const created = await post( diff --git a/src/modules/announcement/application/use-cases/announcement.use-cases.ts b/src/modules/announcement/application/use-cases/announcement.use-cases.ts index de6dc1aa..7bcc9159 100644 --- a/src/modules/announcement/application/use-cases/announcement.use-cases.ts +++ b/src/modules/announcement/application/use-cases/announcement.use-cases.ts @@ -8,9 +8,11 @@ import type { AnnouncementRepository } from '../../domain/repositories/announcem export interface CreateAnnouncementCommand { title: string; body: string; + bodyType?: 'plain' | 'html' | 'md' | 'webview'; actionUrl?: string | null; actionLabel?: string | null; expiresAt?: Date | null; + pinnedAt?: Date | null; actorId: string; requestId?: string | null; } @@ -29,9 +31,11 @@ export class CreateAnnouncementUseCase { const announcement = await this.announcementRepo.create({ title: cmd.title, body: cmd.body, + bodyType: cmd.bodyType ?? 'plain', actionUrl: cmd.actionUrl ?? null, actionLabel: cmd.actionLabel ?? null, expiresAt: cmd.expiresAt ?? null, + pinnedAt: cmd.pinnedAt ?? null, actorId: cmd.actorId, }); @@ -44,6 +48,7 @@ export class CreateAnnouncementUseCase { payload: JSON.stringify({ title: announcement.title, body: announcement.body, + bodyType: announcement.bodyType, actionUrl: announcement.actionUrl, actionLabel: announcement.actionLabel, expiresAt: announcement.expiresAt @@ -69,9 +74,11 @@ export interface UpdateAnnouncementCommand { id: string; title?: string; body?: string; + bodyType?: 'plain' | 'html' | 'md' | 'webview'; actionUrl?: string | null; actionLabel?: string | null; expiresAt?: Date | null; + pinnedAt?: Date | null; // set to now to pin, null to unpin actorId: string; requestId?: string | null; } @@ -90,9 +97,11 @@ export class UpdateAnnouncementUseCase { id: cmd.id, title: cmd.title, body: cmd.body, + bodyType: cmd.bodyType, actionUrl: cmd.actionUrl, actionLabel: cmd.actionLabel, expiresAt: cmd.expiresAt, + pinnedAt: cmd.pinnedAt, actorId: cmd.actorId, }); if (!announcement) { @@ -107,6 +116,7 @@ export class UpdateAnnouncementUseCase { payload: JSON.stringify({ title: announcement.title, body: announcement.body, + bodyType: announcement.bodyType, actionUrl: announcement.actionUrl, actionLabel: announcement.actionLabel, expiresAt: announcement.expiresAt @@ -161,3 +171,12 @@ export class DeleteAnnouncementUseCase { }); } } + +// List pinned announcements (mobile pinned page / carousel). +export class ListPinnedAnnouncementsUseCase { + constructor(private readonly announcementRepo: AnnouncementRepository) {} + + async execute(): Promise { + return this.announcementRepo.listPinned(); + } +} diff --git a/src/modules/announcement/domain/entities/announcement.entity.ts b/src/modules/announcement/domain/entities/announcement.entity.ts index 8a659d16..04723c32 100644 --- a/src/modules/announcement/domain/entities/announcement.entity.ts +++ b/src/modules/announcement/domain/entities/announcement.entity.ts @@ -1,14 +1,19 @@ import type { ActivityEventKind } from '@/modules/activity/domain/entities/activity-event.entity'; +/** Format isi pengumuman (#124 lanjutan). */ +export type AnnouncementBodyType = 'plain' | 'html' | 'md' | 'webview'; + /** Baris `announcements` (#102). */ export interface AnnouncementRow { id: string; title: string; body: string; + bodyType: AnnouncementBodyType; actionUrl: string | null; actionLabel: string | null; createdBy: string; expiresAt: Date | null; + pinnedAt: Date | null; createdAt: Date; updatedAt: Date | null; deletedAt: Date | null; @@ -19,10 +24,12 @@ export interface Announcement { id: string; title: string; body: string; + bodyType: AnnouncementBodyType; actionUrl: string | null; actionLabel: string | null; createdBy: string; expiresAt: Date | null; + pinnedAt: Date | null; createdAt: Date; updatedAt: Date | null; } diff --git a/src/modules/announcement/domain/repositories/announcement.repository.ts b/src/modules/announcement/domain/repositories/announcement.repository.ts index ab38068d..418e27d6 100644 --- a/src/modules/announcement/domain/repositories/announcement.repository.ts +++ b/src/modules/announcement/domain/repositories/announcement.repository.ts @@ -1,4 +1,4 @@ -import type { Announcement } from '../entities/announcement.entity'; +import type { Announcement, AnnouncementBodyType } from '../entities/announcement.entity'; export interface ListAnnouncementsInput { limit: number; @@ -11,21 +11,25 @@ export interface ListAnnouncementsResult { } export interface CreateAnnouncementInput { + bodyType?: AnnouncementBodyType; title: string; body: string; actionUrl?: string | null; actionLabel?: string | null; expiresAt?: Date | null; + pinnedAt?: Date | null; actorId: string; } export interface UpdateAnnouncementInput { + bodyType?: AnnouncementBodyType; id: string; title?: string; body?: string; actionUrl?: string | null; actionLabel?: string | null; expiresAt?: Date | null; + pinnedAt?: Date | null; actorId: string; } @@ -33,6 +37,8 @@ export interface AnnouncementRepository { create(input: CreateAnnouncementInput): Promise; findById(id: string): Promise; list(input: ListAnnouncementsInput): Promise; + /** List pinned announcements (pinnedAt not null, not deleted, not expired). */ + listPinned(): Promise; update(input: UpdateAnnouncementInput): Promise; /** Soft delete. true = terhapus, false = tidak ditemukan/sudah terhapus. */ delete(id: string): Promise; diff --git a/src/modules/announcement/infrastructure/announcement.repository.impl.ts b/src/modules/announcement/infrastructure/announcement.repository.impl.ts index b2df161b..b44a2de9 100644 --- a/src/modules/announcement/infrastructure/announcement.repository.impl.ts +++ b/src/modules/announcement/infrastructure/announcement.repository.impl.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull, lt, desc } from 'drizzle-orm'; +import { and, desc, eq, gt, isNotNull, isNull, lt, or } from 'drizzle-orm'; import type { AppDatabase } from '@/shared/database/drizzle/client'; import { announcements } from '@/shared/database/drizzle/schema/announcements.schema'; import type { @@ -18,10 +18,12 @@ function toDomain(row: AnnouncementRow): Announcement { id: row.id, title: row.title, body: row.body, + bodyType: row.bodyType, actionUrl: row.actionUrl, actionLabel: row.actionLabel, createdBy: row.createdBy, expiresAt: row.expiresAt, + pinnedAt: row.pinnedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -38,10 +40,12 @@ export class AnnouncementRepositoryImpl implements AnnouncementRepository { .values({ title: input.title, body: input.body, + bodyType: input.bodyType ?? 'plain', actionUrl: input.actionUrl ?? null, actionLabel: input.actionLabel ?? null, createdBy: input.actorId, expiresAt: input.expiresAt ?? null, + pinnedAt: input.pinnedAt ?? null, }) .returning(); return toDomain(row); @@ -76,15 +80,33 @@ export class AnnouncementRepositoryImpl implements AnnouncementRepository { }; } + async listPinned(): Promise { + const now = new Date(); + const rows = await this.db + .select() + .from(announcements) + .where( + and( + isNull(announcements.deletedAt), + isNotNull(announcements.pinnedAt), + or(isNull(announcements.expiresAt), gt(announcements.expiresAt, now)), + ), + ) + .orderBy(desc(announcements.pinnedAt)); + return rows.map(toDomain); + } + async update(input: UpdateAnnouncementInput): Promise { const values: Partial = { updatedAt: new Date(), }; if (input.title !== undefined) values.title = input.title; if (input.body !== undefined) values.body = input.body; + if (input.bodyType !== undefined) values.bodyType = input.bodyType; if (input.actionUrl !== undefined) values.actionUrl = input.actionUrl; if (input.actionLabel !== undefined) values.actionLabel = input.actionLabel; if (input.expiresAt !== undefined) values.expiresAt = input.expiresAt; + if (input.pinnedAt !== undefined) values.pinnedAt = input.pinnedAt; const [row] = await this.db .update(announcements) .set(values) diff --git a/src/modules/announcement/presentation/v1/announcement.controller.ts b/src/modules/announcement/presentation/v1/announcement.controller.ts index 729b6a65..719526fd 100644 --- a/src/modules/announcement/presentation/v1/announcement.controller.ts +++ b/src/modules/announcement/presentation/v1/announcement.controller.ts @@ -6,6 +6,7 @@ import type { ListAnnouncementsResult } from '../../domain/repositories/announce import type { CreateAnnouncementUseCase, DeleteAnnouncementUseCase, + ListPinnedAnnouncementsUseCase, UpdateAnnouncementCommand, } from '../../application/use-cases/announcement.use-cases'; import type { ListAnnouncementsUseCase } from '../../application/use-cases/list-announcements.use-case'; @@ -16,9 +17,11 @@ function serialize(a: Announcement) { id: a.id, title: a.title, body: a.body, + body_type: a.bodyType, action_url: a.actionUrl, action_label: a.actionLabel, expires_at: a.expiresAt ? Math.floor(a.expiresAt.getTime() / 1000) : null, + pinned_at: a.pinnedAt ? Math.floor(a.pinnedAt.getTime() / 1000) : null, expired: a.expiresAt ? a.expiresAt.getTime() <= Date.now() : false, created_by: a.createdBy, created_at: Math.floor(a.createdAt.getTime() / 1000), @@ -30,6 +33,7 @@ export class AnnouncementController { constructor( private readonly create: CreateAnnouncementUseCase, private readonly list: ListAnnouncementsUseCase, + private readonly listPinned: ListPinnedAnnouncementsUseCase, private readonly update: (cmd: UpdateAnnouncementCommand) => Promise, private readonly remove: DeleteAnnouncementUseCase, private readonly repo: AnnouncementRepository, @@ -46,18 +50,27 @@ export class AnnouncementController { }); } + // Publik: list pinned announcements (mobile carousel / pinned page). + listPinnedAnnouncements(c: Context<{ Variables: AppVariables }>) { + return this.listPinned.execute().then((items) => + c.json({ success: true as const, data: items.map(serialize) }, 200), + ); + } + createAnnouncement( c: Context<{ Variables: AppVariables }>, - body: { title: string; body: string; action_url?: string | null; action_label?: string | null; expires_at?: number | null }, + body: { title: string; body: string; body_type?: 'plain' | 'html' | 'md' | 'webview'; action_url?: string | null; action_label?: string | null; expires_at?: number | null; pinned_at?: number | null }, ) { const user = c.get('user')!; return this.create .execute({ title: body.title, body: body.body, + bodyType: body.body_type ?? 'plain', actionUrl: body.action_url ?? null, actionLabel: body.action_label ?? null, expiresAt: body.expires_at ? new Date(body.expires_at * 1000) : null, + pinnedAt: body.pinned_at ? new Date(body.pinned_at * 1000) : null, actorId: user.user_id, requestId: c.get('requestId'), }) @@ -81,13 +94,14 @@ export class AnnouncementController { updateAnnouncement( c: Context<{ Variables: AppVariables }>, id: string, - body: { title?: string; body?: string; action_url?: string | null; action_label?: string | null; expires_at?: number | null }, + body: { title?: string; body?: string; body_type?: 'plain' | 'html' | 'md' | 'webview'; action_url?: string | null; action_label?: string | null; expires_at?: number | null; pinned_at?: number | null }, ) { const user = c.get('user')!; return this.update({ id, title: body.title, body: body.body, + bodyType: body.body_type, actionUrl: body.action_url !== undefined ? body.action_url : undefined, actionLabel: body.action_label !== undefined ? body.action_label : undefined, expiresAt: @@ -96,6 +110,12 @@ export class AnnouncementController { : body.expires_at ? new Date(body.expires_at * 1000) : null, + pinnedAt: + body.pinned_at === undefined + ? undefined + : body.pinned_at + ? new Date(body.pinned_at * 1000) + : null, actorId: user.user_id, requestId: c.get('requestId'), }).then((a) => c.json({ success: true as const, data: serialize(a) }, 200)); diff --git a/src/modules/announcement/presentation/v1/announcement.routes.ts b/src/modules/announcement/presentation/v1/announcement.routes.ts index b6f8eab8..6822fddd 100644 --- a/src/modules/announcement/presentation/v1/announcement.routes.ts +++ b/src/modules/announcement/presentation/v1/announcement.routes.ts @@ -113,6 +113,19 @@ export function createPublicAnnouncementRoutes(deps: { controller: AnnouncementC }, }); + const listPinnedRoute = createRoute({ + method: 'get', + path: '/pinned', + tags: ['Announcements'], + summary: 'Daftar pengumuman yang dipin (mobile carousel / halaman pinned)', + responses: { + 200: { description: 'Daftar pengumuman pinned', content: json(announcementListResponseSchema) }, + }, + }); + + // /pinned wajib didaftarkan SEBELUM /:id: Hono match urutan registrasi, + // kalau tidak /pinned nyangkut di /:id (id="pinned" → 400). + routes.openapi(listPinnedRoute, (c) => deps.controller.listPinnedAnnouncements(c) as never); routes.openapi(getRoute, (c) => deps.controller.getAnnouncement(c, c.req.param('id')) as never); return routes; diff --git a/src/modules/announcement/presentation/v1/validators/announcement.validator.ts b/src/modules/announcement/presentation/v1/validators/announcement.validator.ts index ee480586..40b335cf 100644 --- a/src/modules/announcement/presentation/v1/validators/announcement.validator.ts +++ b/src/modules/announcement/presentation/v1/validators/announcement.validator.ts @@ -1,15 +1,8 @@ import { z } from 'zod'; -// Whitelist host action_url: konsisten mobile (skill: open-redirect defense). -// Tambah host = edit sini + mobile `announcement` launcher komentar silang. -const ACTION_URL_ALLOWED_HOSTS = new Set([ - 'sambasku.com', - 'www.sambasku.com', - 'sambasku-staging.iamutaki.com', - 'sambasku.iamutaki.com', - 'play.google.com', -]); - +// #124: action_url = URL https valid apa pun host-nya (kebijakan konten +// admin, bukan teknis). Deep link in-app vs eksternal dibedakan di client. +// Refine: https + parseable. Host lock dihapus. export function actionUrlSchema() { return z .string() @@ -23,26 +16,29 @@ export function actionUrlSchema() { } catch { return false; } - }, 'URL action wajib https') - .refine((v) => { - try { - return ACTION_URL_ALLOWED_HOSTS.has(new URL(v).host.toLowerCase()); - } catch { - return false; - } - }, 'Host URL action tidak diizinkan'); + }, 'URL action wajib https'); } +// #124 lanjutan: format isi pengumuman. webview = client load isi body. +export const bodyTypeSchema = z.enum(['plain', 'html', 'md', 'webview']); + export const createAnnouncementBodySchema = z .object({ title: z.string().trim().min(3).max(120), body: z.string().trim().min(3).max(5000), + body_type: bodyTypeSchema.default('plain'), action_url: actionUrlSchema().nullable().optional(), action_label: z.string().trim().min(2).max(40).nullable().optional(), expires_at: z.number().int().positive().nullable().optional(), + pinned_at: z + .number() + .int() + .positive() + .nullable() + .optional() }) .superRefine((v, ctx) => { - if (v.action_label != null && v.action_url == null) { + if (v.action_label != null && (v.action_url == null || v.action_url === undefined)) { ctx.addIssue({ code: 'custom', path: ['action_label'], @@ -55,9 +51,16 @@ export const updateAnnouncementBodySchema = z .object({ title: z.string().trim().min(3).max(120).optional(), body: z.string().trim().min(3).max(5000).optional(), + body_type: bodyTypeSchema.optional(), action_url: actionUrlSchema().nullable().optional(), action_label: z.string().trim().min(2).max(40).nullable().optional(), expires_at: z.number().int().positive().nullable().optional(), + pinned_at: z + .number() + .int() + .positive() + .nullable() + .optional(), }) .superRefine((v, ctx) => { if (v.action_label != null && v.action_url === undefined) { @@ -78,10 +81,12 @@ const announcementItemSchema = z.object({ id: z.string().length(26), title: z.string(), body: z.string(), + body_type: bodyTypeSchema, action_url: z.string().nullable(), action_label: z.string().nullable(), created_by: z.string(), expires_at: z.number().int().nullable(), + pinned_at: z.number().int().nullable(), expired: z.boolean(), created_at: z.number().int(), updated_at: z.number().int().nullable(), diff --git a/src/modules/auth/presentation/v1/validators/admin-users.validator.ts b/src/modules/auth/presentation/v1/validators/admin-users.validator.ts index 5bef14bd..e214958c 100644 --- a/src/modules/auth/presentation/v1/validators/admin-users.validator.ts +++ b/src/modules/auth/presentation/v1/validators/admin-users.validator.ts @@ -57,7 +57,9 @@ export const createAdminUserBodySchema = z phone: z.string().max(20).optional(), password: z.string().min(8).regex(/[a-zA-Z]/, 'harus mengandung huruf').regex(/[0-9]/, 'harus mengandung angka'), confirm_password: z.string(), - roles: z.array(z.enum(ASSIGNABLE_ADMIN_ROLES)).min(1).max(5), + // Accept both: new `roles` (array) and legacy `role` (string) + roles: z.array(z.enum(ASSIGNABLE_ADMIN_ROLES)).min(1).max(5).optional(), + role: z.enum(ASSIGNABLE_ADMIN_ROLES).optional(), is_active: z.boolean().default(true), }) .superRefine((d, ctx) => { @@ -75,16 +77,26 @@ export const createAdminUserBodySchema = z path: ['phone'], }); } + // At least one of roles or role must be provided + if (!d.roles?.length && !d.role) { + ctx.addIssue({ + code: 'custom', + message: 'Peran wajib diisi (roles atau role)', + path: ['roles'], + }); + } }) .transform((d) => { const phone = normalizePhone(d.phone); + // Normalize: prefer roles array, fallback to single role + const normalizedRoles = d.roles?.length ? [...new Set(d.roles)] : (d.role ? [d.role] : []); return { username: d.username, email: d.email, phone: phone === '__INVALID__' ? null : phone, password: d.password, confirm_password: d.confirm_password, - roles: [...new Set(d.roles)], + roles: normalizedRoles, is_active: d.is_active, }; }); diff --git a/src/modules/category/application/use-cases/category.use-cases.ts b/src/modules/category/application/use-cases/category.use-cases.ts new file mode 100644 index 00000000..70256bb5 --- /dev/null +++ b/src/modules/category/application/use-cases/category.use-cases.ts @@ -0,0 +1,104 @@ +import { NotFoundError } from '@/shared/errors/app-error'; +import type { AuditLogRepository } from '@/modules/audit/domain/repositories/audit-log.repository'; +import type { Category } from '../../domain/entities/category.entity'; +import type { CategoryRepository } from '../../domain/repositories/category.repository'; + +export interface CreateCategoryCommand { + name: string; + description?: string | null; + parentId?: string | null; + actorId: string; + requestId?: string | null; +} + +export class CreateCategoryUseCase { + constructor( + private readonly categoryRepo: CategoryRepository, + private readonly auditRepo?: AuditLogRepository, + ) {} + + async execute(cmd: CreateCategoryCommand): Promise { + const exists = await this.categoryRepo.existsActiveNameCaseInsensitive(cmd.name); + if (exists) { + throw new NotFoundError('CATEGORY_NAME_EXISTS', 'Kategori dengan nama tersebut sudah ada'); + } + + const category = await this.categoryRepo.create({ + name: cmd.name, + description: cmd.description ?? null, + }); + + await this.auditRepo?.record({ + userId: cmd.actorId, + action: 'create', + entityType: 'category', + entityId: category.id, + newData: { name: category.name }, + requestId: cmd.requestId ?? null, + }); + + return category; + } +} + +export interface UpdateCategoryCommand { + id: string; + name?: string; + description?: string | null; + parentId?: string | null; + actorId: string; + requestId?: string | null; +} + +export class UpdateCategoryUseCase { + constructor( + private readonly categoryRepo: CategoryRepository, + private readonly auditRepo?: AuditLogRepository, + ) {} + + async execute(cmd: UpdateCategoryCommand): Promise { + const category = await this.categoryRepo.update(cmd.id, { + name: cmd.name, + description: cmd.description, + parentId: cmd.parentId, + }); + + if (!category) { + throw new NotFoundError('CATEGORY_NOT_FOUND', 'Kategori dengan id tersebut tidak ditemukan'); + } + + await this.auditRepo?.record({ + userId: cmd.actorId, + action: 'update', + entityType: 'category', + entityId: category.id, + newData: { name: category.name }, + requestId: cmd.requestId ?? null, + }); + + return category; + } +} + +export class DeleteCategoryUseCase { + constructor( + private readonly categoryRepo: CategoryRepository, + private readonly auditRepo?: AuditLogRepository, + ) {} + + async execute(cmd: { id: string; actorId: string; requestId?: string | null }): Promise { + const ok = await this.categoryRepo.softDelete(cmd.id); + if (!ok) { + throw new NotFoundError('CATEGORY_NOT_FOUND', 'Kategori dengan id tersebut tidak ditemukan'); + } + + await this.auditRepo?.record({ + userId: cmd.actorId, + action: 'delete', + entityType: 'category', + entityId: cmd.id, + newData: { deleted: true }, + requestId: cmd.requestId ?? null, + }); + } +} \ No newline at end of file diff --git a/src/modules/category/domain/repositories/category.repository.ts b/src/modules/category/domain/repositories/category.repository.ts index dfe1d69a..d22846df 100644 --- a/src/modules/category/domain/repositories/category.repository.ts +++ b/src/modules/category/domain/repositories/category.repository.ts @@ -7,4 +7,6 @@ export interface CategoryRepository { findActiveByNameCaseInsensitive(name: string): Promise; /** INSERT kategori baru dari approve usulan (api#50). */ create(input: { name: string; description?: string | null }): Promise; + update(id: string, input: { name?: string; description?: string | null; parentId?: string | null }): Promise; + softDelete(id: string): Promise; } \ No newline at end of file diff --git a/src/modules/category/infrastructure/category.repository.impl.ts b/src/modules/category/infrastructure/category.repository.impl.ts index e51db6cc..f0775d78 100644 --- a/src/modules/category/infrastructure/category.repository.impl.ts +++ b/src/modules/category/infrastructure/category.repository.impl.ts @@ -1,4 +1,4 @@ -import { asc, isNull, sql } from 'drizzle-orm'; +import { asc, and, eq, isNull, sql } from 'drizzle-orm'; import { categories } from '@/shared/database/drizzle/schema'; import type { AppDatabase } from '@/shared/database/drizzle/client'; import type { Category } from '../domain/entities/category.entity'; @@ -60,4 +60,33 @@ export class CategoryRepositoryImpl implements CategoryRepository { const r = rows[0]; return { id: r.id, parentId: r.parentId, name: r.name, description: r.description }; } + + async update( + id: string, + input: { name?: string; description?: string | null; parentId?: string | null }, + ): Promise { + const values: Partial = { + updatedAt: new Date(), + }; + if (input.name !== undefined) values.name = input.name; + if (input.description !== undefined) values.description = input.description; + if (input.parentId !== undefined) values.parentId = input.parentId; + const rows = await this.db + .update(categories) + .set(values) + .where(and(eq(categories.id, id), isNull(categories.deletedAt))) + .returning(); + const r = rows[0]; + if (!r) return null; + return { id: r.id, parentId: r.parentId, name: r.name, description: r.description }; + } + + async softDelete(id: string): Promise { + const rows = await this.db + .update(categories) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(categories.id, id), isNull(categories.deletedAt))) + .returning({ id: categories.id }); + return rows.length > 0; + } } \ No newline at end of file diff --git a/src/modules/category/presentation/v1/category.controller.ts b/src/modules/category/presentation/v1/category.controller.ts index 3074cd26..f74ed6a2 100644 --- a/src/modules/category/presentation/v1/category.controller.ts +++ b/src/modules/category/presentation/v1/category.controller.ts @@ -1,20 +1,79 @@ import type { Context } from 'hono'; +import type { AppVariables } from '@/shared/types'; +import type { Category } from '../../domain/entities/category.entity'; import type { ListCategoriesUseCase } from '../../application/use-cases/list-categories.use-case'; +import type { + CreateCategoryUseCase, + DeleteCategoryUseCase, + UpdateCategoryUseCase, +} from '../../application/use-cases/category.use-cases'; + +function serialize(c: Category) { + return { + id: c.id, + parent_id: c.parentId, + name: c.name, + description: c.description, + word_count: c.wordCount ?? 0, + }; +} export class CategoryController { - constructor(private readonly deps: { listCategories: ListCategoriesUseCase }) {} + constructor( + private readonly deps: { + listCategories: ListCategoriesUseCase; + createCategory: CreateCategoryUseCase; + updateCategory: UpdateCategoryUseCase; + deleteCategory: DeleteCategoryUseCase; + }, + ) {} async categories(c: Context) { const items = await this.deps.listCategories.execute(); return c.json({ success: true as const, - data: items.map((cat) => ({ - id: cat.id, - parent_id: cat.parentId, - name: cat.name, - description: cat.description, - word_count: cat.wordCount ?? 0, - })), + data: items.map(serialize), }); } -} + + createCategory( + c: Context<{ Variables: AppVariables }>, + body: { name: string; description?: string | null; parent_id?: string | null }, + ) { + const user = c.get('user')!; + return this.deps.createCategory + .execute({ + name: body.name, + description: body.description ?? null, + parentId: body.parent_id ?? null, + actorId: user.user_id, + requestId: c.get('requestId'), + }) + .then((cat) => c.json({ success: true as const, data: serialize(cat) }, 201)); + } + + updateCategory( + c: Context<{ Variables: AppVariables }>, + id: string, + body: { name?: string; description?: string | null; parent_id?: string | null }, + ) { + const user = c.get('user')!; + return this.deps.updateCategory + .execute({ + id, + name: body.name, + description: body.description, + parentId: body.parent_id, + actorId: user.user_id, + requestId: c.get('requestId'), + }) + .then((cat) => c.json({ success: true as const, data: serialize(cat) }, 200)); + } + + deleteCategory(c: Context<{ Variables: AppVariables }>, id: string) { + const user = c.get('user')!; + return this.deps.deleteCategory + .execute({ id, actorId: user.user_id, requestId: c.get('requestId') }) + .then(() => c.json({ success: true as const, data: null }, 200)); + } +} \ No newline at end of file diff --git a/src/modules/category/presentation/v1/category.routes.ts b/src/modules/category/presentation/v1/category.routes.ts index 0f5cb9d4..44b00968 100644 --- a/src/modules/category/presentation/v1/category.routes.ts +++ b/src/modules/category/presentation/v1/category.routes.ts @@ -1,28 +1,57 @@ +import type { MiddlewareHandler } from 'hono'; import { createRoute } from '@hono/zod-openapi'; import { z } from 'zod'; +import { authorizeRole } from '@/shared/middlewares/authorize-role.middleware'; import { rateLimit } from '@/shared/middlewares/rate-limit.middleware'; import { createOpenApiApp } from '@/shared/openapi/openapi-app'; +import { errorResponseSchema, okNullResponseSchema } from '@/shared/openapi/error-response.schema'; +import type { AppVariables } from '@/shared/types'; import type { CategoryController } from './category.controller'; const json = (schema: T) => ({ 'application/json': { schema }, }); +const categoryItemSchema = z.object({ + id: z.string(), + parent_id: z.string().nullable(), + name: z.string(), + description: z.string().nullable(), + word_count: z.number(), +}); + const categoryListResponseSchema = z.object({ success: z.literal(true), - data: z.array( - z.object({ - id: z.string(), - parent_id: z.string().nullable(), - name: z.string(), - description: z.string().nullable(), - word_count: z.number(), - }), - ), + data: z.array(categoryItemSchema), +}); + +const categoryItemResponseSchema = z.object({ + success: z.literal(true), + data: categoryItemSchema, +}); + +export const createCategoryBodySchema = z.object({ + name: z.string().min(1).max(60), + description: z.string().nullable().optional(), + parent_id: z.string().nullable().optional(), +}); + +export const updateCategoryBodySchema = z.object({ + name: z.string().min(1).max(60).optional(), + description: z.string().nullable().optional(), + parent_id: z.string().nullable().optional(), }); -// GET /api/v1/categories - multi-select kategori form admin -export function createCategoryRoutes(deps: { controller: CategoryController }) { +const idParam = z.object({ id: z.string() }); + +export interface CategoryRoutesDeps { + controller: CategoryController; + authenticate: MiddlewareHandler<{ Variables: AppVariables }>; +} + +// GET /api/v1/categories - multi-select kategori form admin (publik baca) +// POST/PUT/DELETE - master kategori keur (admin) +export function createCategoryRoutes(deps: CategoryRoutesDeps) { const routes = createOpenApiApp(); routes.use('*', rateLimit({ points: 100, duration: 60 })); // publik baca (Section 15) @@ -36,6 +65,54 @@ export function createCategoryRoutes(deps: { controller: CategoryController }) { }, }); + const createRouteDef = createRoute({ + method: 'post', + path: '/', + tags: ['Categories'], + summary: 'Buat kategori master', + request: { body: { content: { 'application/json': { schema: createCategoryBodySchema } } } }, + responses: { + 201: { description: 'Kategori dibuat', content: json(categoryItemResponseSchema) }, + 400: { description: 'Body tidak valid', content: json(errorResponseSchema) }, + }, + }); + + const updateRouteDef = createRoute({ + method: 'put', + path: '/:id', + tags: ['Categories'], + summary: 'Edit kategori master', + request: { + params: idParam, + body: { content: { 'application/json': { schema: updateCategoryBodySchema } } }, + }, + responses: { + 200: { description: 'Kategori diperbarui', content: json(categoryItemResponseSchema) }, + 400: { description: 'Body tidak valid', content: json(errorResponseSchema) }, + 404: { description: 'Tidak ditemukan', content: json(errorResponseSchema) }, + }, + }); + + const deleteRouteDef = createRoute({ + method: 'delete', + path: '/:id', + tags: ['Categories'], + summary: 'Hapus kategori master (soft delete)', + request: { params: idParam }, + responses: { + 200: { description: 'Terhapus', content: json(okNullResponseSchema) }, + 404: { description: 'Tidak ditemukan', content: json(errorResponseSchema) }, + }, + }); + routes.openapi(listRoute, (c) => deps.controller.categories(c) as never); + + // Mutasi master kategori: wajib auth + root|admin (keputusan issue - + // reviewer/editor tidak boleh edit master data). + routes.use('*', deps.authenticate, authorizeRole('root', 'admin')); + routes.openapi(createRouteDef, (c) => deps.controller.createCategory(c, c.req.valid('json')) as never); + routes.openapi(updateRouteDef, (c) => deps.controller.updateCategory(c, c.req.param('id'), c.req.valid('json')) as never); + routes.openapi(deleteRouteDef, (c) => deps.controller.deleteCategory(c, c.req.param('id')) as never); + return routes; -} +} \ No newline at end of file diff --git a/src/modules/device/application/ports/push-sender.port.ts b/src/modules/device/application/ports/push-sender.port.ts index bf3e04c4..ff26eda4 100644 --- a/src/modules/device/application/ports/push-sender.port.ts +++ b/src/modules/device/application/ports/push-sender.port.ts @@ -1,6 +1,7 @@ export interface PushMessage { title: string; body: string; + bodyType?: 'plain' | 'html' | 'md' | 'webview'; /** HTTPS URL untuk rich image (Android tray / FCM notification.image). */ imageUrl?: string; data?: Record; diff --git a/src/modules/device/infrastructure/fcm-push.sender.ts b/src/modules/device/infrastructure/fcm-push.sender.ts index 390c33bf..f10812c3 100644 --- a/src/modules/device/infrastructure/fcm-push.sender.ts +++ b/src/modules/device/infrastructure/fcm-push.sender.ts @@ -30,6 +30,10 @@ async function pushOne( ...(message.data && Object.keys(message.data).length > 0 ? { data: message.data } : {}), + data: { + ...(message.data || {}), + ...(message.bodyType ? { body_type: message.bodyType } : {}), + }, }, }; @@ -77,6 +81,10 @@ async function pushTopic( ...(message.data && Object.keys(message.data).length > 0 ? { data: message.data } : {}), + data: { + ...(message.data || {}), + ...(message.bodyType ? { body_type: message.bodyType } : {}), + }, }, }; diff --git a/src/modules/notification-campaign/__tests__/unit/campaign.use-case.test.ts b/src/modules/notification-campaign/__tests__/unit/campaign.use-case.test.ts index 1021a37d..aedbda6e 100644 --- a/src/modules/notification-campaign/__tests__/unit/campaign.use-case.test.ts +++ b/src/modules/notification-campaign/__tests__/unit/campaign.use-case.test.ts @@ -17,6 +17,7 @@ function campaign(partial: Partial = {}): NotificationCamp templateId: null, title: 'Hallo', body: 'Isi pengumuman', + bodyType: 'plain', imageUrl: null, deepLinkKind: 'none', deepLinkValue: null, @@ -86,6 +87,7 @@ describe('CreateCampaignDraftUseCase', () => { name: 'T', title: 'Dari template', body: 'Body template', + bodyType: 'plain', imageUrl: 'https://cdn.jsdelivr.net/gh/sambasku/images@main/assets/campaigns/x.webp', deepLinkKind: 'none', deepLinkValue: null, diff --git a/src/modules/notification-campaign/application/use-cases/campaign.use-cases.ts b/src/modules/notification-campaign/application/use-cases/campaign.use-cases.ts index 58fbee4c..0b878552 100644 --- a/src/modules/notification-campaign/application/use-cases/campaign.use-cases.ts +++ b/src/modules/notification-campaign/application/use-cases/campaign.use-cases.ts @@ -286,6 +286,7 @@ export class ProcessCampaignDeliveryUseCase { const ok = await this.pushSender.sendToTopic(CAMPAIGN_FCM_TOPIC, { title: current.title, body: current.body, + bodyType: current.bodyType, imageUrl: current.imageUrl ?? undefined, data: buildCampaignPushData(current), }); @@ -330,6 +331,7 @@ export class ProcessCampaignDeliveryUseCase { type: 'campaign' as const, title: current.title, body: current.body, + bodyType: current.bodyType, imageUrl: current.imageUrl, targetKind: 'campaign' as const, targetId: current.id, @@ -380,6 +382,7 @@ export class ProcessCampaignDeliveryUseCase { type: 'campaign', title: campaign.title, body: campaign.body, + bodyType: campaign.bodyType, imageUrl: campaign.imageUrl, targetKind: 'campaign', targetId: campaign.id, @@ -391,6 +394,7 @@ export class ProcessCampaignDeliveryUseCase { const result = await this.pushSender.send(tokens, { title: campaign.title, body: campaign.body, + bodyType: campaign.bodyType, imageUrl: campaign.imageUrl ?? undefined, data: buildCampaignPushData(campaign), }); diff --git a/src/modules/notification-campaign/domain/entities/campaign.entity.ts b/src/modules/notification-campaign/domain/entities/campaign.entity.ts index fd2e61bb..e2554ae0 100644 --- a/src/modules/notification-campaign/domain/entities/campaign.entity.ts +++ b/src/modules/notification-campaign/domain/entities/campaign.entity.ts @@ -9,6 +9,8 @@ export const CAMPAIGN_MAX_CHUNKS_PER_RUN = 5; export type DeepLinkKind = 'word' | 'contribution' | 'suggestion' | 'url' | 'none'; +export type CampaignBodyType = 'plain' | 'html' | 'md' | 'webview'; + export type CampaignAudienceType = 'all' | 'selected'; export type CampaignStatus = @@ -30,6 +32,7 @@ export interface NotificationTemplate { name: string; title: string; body: string; + bodyType: CampaignBodyType; imageUrl: string | null; deepLinkKind: DeepLinkKind; deepLinkValue: string | null; @@ -44,6 +47,7 @@ export interface NotificationCampaign { templateId: string | null; title: string; body: string; + bodyType: CampaignBodyType; imageUrl: string | null; deepLinkKind: DeepLinkKind; deepLinkValue: string | null; @@ -77,6 +81,7 @@ export function buildCampaignPushData(campaign: { deepLinkKind: DeepLinkKind; deepLinkValue: string | null; imageUrl?: string | null; + bodyType?: CampaignBodyType; }): Record { const data: Record = { type: 'campaign', @@ -87,6 +92,9 @@ export function buildCampaignPushData(campaign: { if (campaign.imageUrl) { data.image_url = campaign.imageUrl; } + if (campaign.bodyType) { + data.body_type = campaign.bodyType; + } if (campaign.deepLinkKind && campaign.deepLinkKind !== 'none' && campaign.deepLinkValue) { data.deep_link_kind = campaign.deepLinkKind; data.deep_link_value = campaign.deepLinkValue; diff --git a/src/modules/notification-campaign/infrastructure/notification-campaign.repository.impl.ts b/src/modules/notification-campaign/infrastructure/notification-campaign.repository.impl.ts index 8ac0dff2..8a27dc31 100644 --- a/src/modules/notification-campaign/infrastructure/notification-campaign.repository.impl.ts +++ b/src/modules/notification-campaign/infrastructure/notification-campaign.repository.impl.ts @@ -8,6 +8,7 @@ import { import type { AppDatabase } from '@/shared/database/drizzle/client'; import type { CampaignAudienceType, + CampaignBodyType, CampaignRecipient, CampaignRecipientStatus, CampaignStatus, @@ -33,6 +34,7 @@ function toTemplate(row: typeof notificationTemplates.$inferSelect): Notificatio name: row.name, title: row.title, body: row.body, + bodyType: row.bodyType as CampaignBodyType, imageUrl: row.imageUrl ?? null, deepLinkKind: row.deepLinkKind as DeepLinkKind, deepLinkValue: row.deepLinkValue, @@ -49,6 +51,7 @@ function toCampaign(row: typeof notificationCampaigns.$inferSelect): Notificatio templateId: row.templateId, title: row.title, body: row.body, + bodyType: row.bodyType as CampaignBodyType, imageUrl: row.imageUrl ?? null, deepLinkKind: row.deepLinkKind as DeepLinkKind, deepLinkValue: row.deepLinkValue, diff --git a/src/modules/notification-campaign/presentation/v1/map-campaign.ts b/src/modules/notification-campaign/presentation/v1/map-campaign.ts index 84ac8c10..5a8b0e77 100644 --- a/src/modules/notification-campaign/presentation/v1/map-campaign.ts +++ b/src/modules/notification-campaign/presentation/v1/map-campaign.ts @@ -10,6 +10,7 @@ export function mapTemplate(t: NotificationTemplate) { name: t.name, title: t.title, body: t.body, + bodyType: t.bodyType, image_url: t.imageUrl, deep_link_kind: t.deepLinkKind as DeepLinkKind, deep_link_value: t.deepLinkValue, @@ -25,6 +26,7 @@ export function mapCampaign(c: NotificationCampaign) { template_id: c.templateId, title: c.title, body: c.body, + bodyType: c.bodyType, image_url: c.imageUrl, deep_link_kind: c.deepLinkKind, deep_link_value: c.deepLinkValue, diff --git a/src/modules/notification-campaign/presentation/v1/validators/campaign.validator.ts b/src/modules/notification-campaign/presentation/v1/validators/campaign.validator.ts index 98ddddfa..e7db5b39 100644 --- a/src/modules/notification-campaign/presentation/v1/validators/campaign.validator.ts +++ b/src/modules/notification-campaign/presentation/v1/validators/campaign.validator.ts @@ -9,6 +9,8 @@ export const deepLinkKindSchema = z.enum([ 'none', ]); +export const campaignBodyTypeSchema = z.enum(['plain', 'html', 'md', 'webview']).default('plain'); + /** Host yang boleh dipakai deep_link_kind=url. Sinkron dengan mobile: * lib/core/services/notification_navigation.dart - kAllowedNotificationHosts. */ const ALLOWED_DEEP_LINK_HOSTS = new Set([ @@ -101,6 +103,7 @@ export const createTemplateBodySchema = refineDeepLinkUrl( .max(100, 'Nama template maksimal 100 karakter'), title: titleSchema, body: bodySchema, + bodyType: campaignBodyTypeSchema, image_url: imageUrlSchema, deep_link_kind: deepLinkKindSchema.default('none'), deep_link_value: z.string().trim().max(500).nullable().optional(), @@ -112,6 +115,7 @@ export const updateTemplateBodySchema = refineDeepLinkUrl( name: z.string().trim().min(1).max(100).optional(), title: titleSchema.optional(), body: bodySchema.optional(), + bodyType: campaignBodyTypeSchema.optional(), image_url: imageUrlSchema, deep_link_kind: deepLinkKindSchema.optional(), deep_link_value: z.string().trim().max(500).nullable().optional(), @@ -159,6 +163,7 @@ export const createCampaignBodySchema = refineDeepLinkUrl( template_id: opaqueId.nullable().optional(), title: titleSchema.optional(), body: bodySchema.optional(), + bodyType: campaignBodyTypeSchema.optional(), image_url: imageUrlSchema, deep_link_kind: deepLinkKindSchema.optional(), deep_link_value: z.string().trim().max(500).nullable().optional(), diff --git a/src/modules/notification/__tests__/e2e/v1/notification.e2e.test.ts b/src/modules/notification/__tests__/e2e/v1/notification.e2e.test.ts index 35372ede..cafe34bc 100644 --- a/src/modules/notification/__tests__/e2e/v1/notification.e2e.test.ts +++ b/src/modules/notification/__tests__/e2e/v1/notification.e2e.test.ts @@ -119,6 +119,7 @@ describe.skipIf(!hasTestDb)('Notification inbox E2E v1 (23-api-notifications.md) expect(listBody.data).toHaveLength(1); expect(listBody.data[0]).toMatchObject({ type: 'contribution_approved', + body_type: 'plain', target_kind: 'contribution', target_id: item.id, action_kind: null, diff --git a/src/modules/notification/application/use-cases/record-inbox-notification.use-case.ts b/src/modules/notification/application/use-cases/record-inbox-notification.use-case.ts index 5cc1a528..7e85547b 100644 --- a/src/modules/notification/application/use-cases/record-inbox-notification.use-case.ts +++ b/src/modules/notification/application/use-cases/record-inbox-notification.use-case.ts @@ -17,6 +17,8 @@ export interface RecordInboxNotificationCommand { title?: string; /** Menimpa salinan bawaan, misalnya menyertakan lemma. */ body?: string; + /** Tipe body eksplisit: plain | html | md | webview. Default dari inboxCopyFor(type). */ + bodyType?: 'plain' | 'html' | 'md' | 'webview'; /** Takedown ulang pada kata yang sama: tulis ulang dan tandai belum dibaca. */ refreshOnConflict?: boolean; /** Pelaku aksi; jika sama dengan userId, notifikasi diri sendiri dilewati. */ @@ -53,6 +55,7 @@ export class RecordInboxNotificationUseCase { type: cmd.type, title: cmd.title?.trim() ? cmd.title.trim() : copy.title, body: cmd.body?.trim() ? cmd.body.trim() : copy.body, + bodyType: cmd.bodyType ?? copy.bodyType, targetKind: cmd.targetKind, targetId: cmd.targetId, actionKind: cmd.actionKind ?? null, diff --git a/src/modules/notification/domain/entities/notification.entity.ts b/src/modules/notification/domain/entities/notification.entity.ts index 8f56f697..48435f59 100644 --- a/src/modules/notification/domain/entities/notification.entity.ts +++ b/src/modules/notification/domain/entities/notification.entity.ts @@ -43,6 +43,7 @@ export interface InboxNotification { type: InboxNotificationType; title: string; body: string; + bodyType: 'plain' | 'html' | 'md' | 'webview'; imageUrl: string | null; targetKind: NotificationTargetKind; targetId: string; @@ -52,110 +53,130 @@ export interface InboxNotification { createdAt: Date; } -export function inboxCopyFor(type: InboxNotificationType): { title: string; body: string } { +export function inboxCopyFor(type: InboxNotificationType): { title: string; body: string; bodyType: 'plain' | 'html' | 'md' | 'webview' } { switch (type) { case 'contribution_approved': return { title: 'Kata sudah dicek', body: 'Tim sudah memeriksa usulanmu. Labelnya sekarang Terverifikasi.', + bodyType: 'plain', }; case 'contribution_rejected': return { title: 'Kata ditarik', body: 'Usulanmu ditarik dari kamus. Buka Kontribusi Saya untuk melihat alasan.', + bodyType: 'plain', }; case 'contribution_corrected': return { title: 'Usulan dikoreksi', body: 'Tim mengoreksi usulanmu. Entri tetap tayang dan sudah dicek.', + bodyType: 'plain', }; case 'suggestion_approved': return { title: 'Usulan perubahan selesai', body: 'Usulan perubahanmu selesai diperiksa.', + bodyType: 'plain', }; case 'suggestion_rejected': return { title: 'Usulan perubahan ditolak', body: 'Usulan perubahan ditolak. Buka Kontribusi Saya untuk melihat hasilnya.', + bodyType: 'plain', }; case 'suggestion_corrected': return { title: 'Usulan perubahan dikoreksi', body: 'Tim mengoreksi usulan perubahanmu dan menerapkannya.', + bodyType: 'plain', }; case 'word_taken_down': return { title: 'Entri ditarik', body: 'Entri yang kamu buat ditarik dari kamus.', + bodyType: 'plain', }; case 'contribution_paused': return { title: 'Kontribusi dihentikan', body: 'Kamu belum bisa mengirim usulan baru. Usulan yang sudah tayang tetap ada.', + bodyType: 'plain', }; case 'contribution_resumed': return { title: 'Kontribusi dibuka lagi', body: 'Kamu bisa mengirim usulan lagi.', + bodyType: 'plain', }; case 'discussion_pending_review': return { title: 'Diskusi menunggu tinjauan', body: 'Ada diskusi baru yang menunggu pemeriksaan.', + bodyType: 'plain', }; case 'discussion_approved': return { title: 'Diskusi tayang', body: 'Diskusimu sudah diperiksa dan tayang di feed.', + bodyType: 'plain', }; case 'discussion_rejected': return { title: 'Diskusi ditolak', body: 'Diskusimu ditolak. Buka riwayat untuk melihat alasan.', + bodyType: 'plain', }; case 'discussion_taken_down': return { title: 'Diskusi ditarik', body: 'Diskusimu ditarik dari feed.', + bodyType: 'plain', }; case 'discussion_reply': return { title: 'Balasan baru', body: 'Ada balasan baru di Ruang Diskusi.', + bodyType: 'plain', }; case 'discussion_mention': return { title: 'Kamu disebut di diskusi', body: 'Seseorang menyebutmu di Ruang Diskusi.', + bodyType: 'plain', }; case 'word_comment': return { title: 'Komentar baru', body: 'Ada komentar baru di diskusi kosakata.', + bodyType: 'plain', }; case 'word_comment_mention': return { title: 'Kamu disebut di komentar', body: 'Seseorang menyebutmu di komentar kosakata.', + bodyType: 'plain', }; case 'word_vote': return { title: 'Vote baru', body: 'Ada penilaian baru pada kosakatamu.', + bodyType: 'plain', }; case 'campaign': // Title/body campaign selalu dari snapshot admin (bukan copy bawaan). - return { title: 'Pengumuman', body: '' }; + return { title: 'Pengumuman', body: '', bodyType: 'plain' }; case 'verifier_application_approved': return { title: 'Selamat, kamu jadi verifikator', body: 'Pengajuanmu disetujui. Keluar lalu masuk lagi ya, biar peran Verifikator aktif di aplikasi.', + bodyType: 'plain', }; case 'verifier_application_rejected': return { title: 'Pengajuan verifikator ditolak', body: 'Pengajuan ditolak. Buka profil untuk memperbaiki.', + bodyType: 'plain', }; } } diff --git a/src/modules/notification/domain/repositories/notification.repository.ts b/src/modules/notification/domain/repositories/notification.repository.ts index 104633b1..8d48351b 100644 --- a/src/modules/notification/domain/repositories/notification.repository.ts +++ b/src/modules/notification/domain/repositories/notification.repository.ts @@ -10,6 +10,7 @@ export interface CreateInboxNotificationInput { type: InboxNotificationType; title: string; body: string; + bodyType?: 'plain' | 'html' | 'md' | 'webview'; imageUrl?: string | null; targetKind: NotificationTargetKind; targetId: string; diff --git a/src/modules/notification/infrastructure/notification.repository.impl.ts b/src/modules/notification/infrastructure/notification.repository.impl.ts index 050419d4..4c5cb16c 100644 --- a/src/modules/notification/infrastructure/notification.repository.impl.ts +++ b/src/modules/notification/infrastructure/notification.repository.impl.ts @@ -28,6 +28,7 @@ function toEntity(row: typeof notifications.$inferSelect): InboxNotification { type: row.type as InboxNotificationType, title: row.title, body: row.body, + bodyType: row.bodyType, imageUrl: row.imageUrl ?? null, targetKind: row.targetKind as NotificationTargetKind, targetId: row.targetId, @@ -44,6 +45,7 @@ function rowValues(input: CreateInboxNotificationInput) { type: input.type, title: input.title, body: input.body, + bodyType: input.bodyType ?? 'plain', imageUrl: input.imageUrl ?? null, targetKind: input.targetKind, targetId: input.targetId, @@ -85,6 +87,7 @@ export class NotificationRepositoryImpl implements NotificationRepository { set: { title: input.title, body: input.body, + bodyType: input.bodyType ?? 'plain', imageUrl: input.imageUrl ?? null, actionKind: input.actionKind ?? null, actionValue: input.actionValue ?? null, diff --git a/src/modules/notification/presentation/v1/notification.controller.ts b/src/modules/notification/presentation/v1/notification.controller.ts index b67682e5..c21a0c04 100644 --- a/src/modules/notification/presentation/v1/notification.controller.ts +++ b/src/modules/notification/presentation/v1/notification.controller.ts @@ -32,6 +32,7 @@ export class NotificationController { type: n.type, title: n.title, body: n.body, + body_type: n.bodyType, image_url: n.imageUrl, target_kind: n.targetKind, target_id: n.targetId, diff --git a/src/modules/notification/presentation/v1/validators/notification.validator.ts b/src/modules/notification/presentation/v1/validators/notification.validator.ts index 4026d3e4..6a60f2f5 100644 --- a/src/modules/notification/presentation/v1/validators/notification.validator.ts +++ b/src/modules/notification/presentation/v1/validators/notification.validator.ts @@ -60,6 +60,7 @@ const notificationItemSchema = z.object({ type: inboxNotificationTypeSchema, title: z.string(), body: z.string(), + body_type: z.enum(['plain', 'html', 'md', 'webview']), image_url: z.string().nullable(), target_kind: notificationTargetKindSchema, target_id: z.string(), diff --git a/src/shared/database/drizzle/migrations/0066_announcements_body_type.sql b/src/shared/database/drizzle/migrations/0066_announcements_body_type.sql new file mode 100644 index 00000000..55921d21 --- /dev/null +++ b/src/shared/database/drizzle/migrations/0066_announcements_body_type.sql @@ -0,0 +1 @@ +ALTER TABLE `announcements` ADD `body_type` text NOT NULL DEFAULT 'plain'; diff --git a/src/shared/database/drizzle/migrations/0067_notifications_body_type.sql b/src/shared/database/drizzle/migrations/0067_notifications_body_type.sql new file mode 100644 index 00000000..c9415275 --- /dev/null +++ b/src/shared/database/drizzle/migrations/0067_notifications_body_type.sql @@ -0,0 +1 @@ +ALTER TABLE `notifications` ADD `body_type` text NOT NULL DEFAULT 'plain'; \ No newline at end of file diff --git a/src/shared/database/drizzle/migrations/0068_announcements_pinned_at.sql b/src/shared/database/drizzle/migrations/0068_announcements_pinned_at.sql new file mode 100644 index 00000000..da5b86d9 --- /dev/null +++ b/src/shared/database/drizzle/migrations/0068_announcements_pinned_at.sql @@ -0,0 +1 @@ +ALTER TABLE `announcements` ADD `pinned_at` integer; \ No newline at end of file diff --git a/src/shared/database/drizzle/migrations/meta/_journal.json b/src/shared/database/drizzle/migrations/meta/_journal.json index 71428cbe..b3d7d00a 100644 --- a/src/shared/database/drizzle/migrations/meta/_journal.json +++ b/src/shared/database/drizzle/migrations/meta/_journal.json @@ -463,6 +463,27 @@ "when": 1799946600000, "tag": "0065_announcements", "breakpoints": true + }, + { + "idx": 66, + "version": "6", + "when": 1799946700000, + "tag": "0066_announcements_body_type", + "breakpoints": true + }, + { + "idx": 67, + "version": "6", + "when": 1799946800000, + "tag": "0067_notifications_body_type", + "breakpoints": true + }, + { + "idx": 68, + "version": "6", + "when": 1799946900000, + "tag": "0068_announcements_pinned_at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/shared/database/drizzle/schema/announcements.schema.ts b/src/shared/database/drizzle/schema/announcements.schema.ts index f102f3e1..d696cb13 100644 --- a/src/shared/database/drizzle/schema/announcements.schema.ts +++ b/src/shared/database/drizzle/schema/announcements.schema.ts @@ -6,12 +6,18 @@ import { users } from './users.schema'; // `announcement` (write-through activity_events saat create) + halaman // detail in-app mobile. Action = buka link eksternal (opsional, nullable). // Masa berlaku: expires_at opsional - feed menyaring yang kadaluarsa. +// Pinned: pinned_at tidak null = tampil di halaman pinned/carousel mobile. export const announcements = sqliteTable( 'announcements', { id: text('id').primaryKey().$defaultFn(() => generateId()), title: text('title').notNull(), body: text('body').notNull(), + // #124 lanjutan: format isi - plain | html | md | webview. + // html = render native client; webview = client load body (URL/HTML). + bodyType: text('body_type', { + enum: ['plain', 'html', 'md', 'webview'], + }).notNull().default('plain'), // URL https eksternal (opsional). Konsisten whitelist mobile - kosong // = pengumuman tanpa action. actionUrl: text('action_url'), @@ -20,6 +26,7 @@ export const announcements = sqliteTable( .notNull() .references(() => users.id), expiresAt: integer('expires_at', { mode: 'timestamp' }), + pinnedAt: integer('pinned_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }), deletedAt: integer('deleted_at', { mode: 'timestamp' }), @@ -27,5 +34,7 @@ export const announcements = sqliteTable( (t) => [ // List admin: terbaru dulu. index('announcements_created_at_idx').on(t.createdAt), + // Pinned query: pinned terbaru dulu, filter deletedAt null. + index('announcements_pinned_idx').on(t.pinnedAt), ], ); diff --git a/src/shared/database/drizzle/schema/notification-campaigns.schema.ts b/src/shared/database/drizzle/schema/notification-campaigns.schema.ts index f7414aa9..642b8242 100644 --- a/src/shared/database/drizzle/schema/notification-campaigns.schema.ts +++ b/src/shared/database/drizzle/schema/notification-campaigns.schema.ts @@ -15,6 +15,9 @@ export const notificationCampaigns = sqliteTable( /** Snapshot copy saat draft/send. */ title: text('title').notNull(), body: text('body').notNull(), + bodyType: text('body_type', { enum: ['plain', 'html', 'md', 'webview'] }) + .notNull() + .default('plain'), /** URL gambar opsional (snapshot) untuk rich push / inbox. */ imageUrl: text('image_url'), deepLinkKind: text('deep_link_kind').notNull().default('none'), diff --git a/src/shared/database/drizzle/schema/notification-templates.schema.ts b/src/shared/database/drizzle/schema/notification-templates.schema.ts index 311794b3..4a68a6f6 100644 --- a/src/shared/database/drizzle/schema/notification-templates.schema.ts +++ b/src/shared/database/drizzle/schema/notification-templates.schema.ts @@ -11,6 +11,9 @@ export const notificationTemplates = sqliteTable( name: text('name').notNull(), title: text('title').notNull(), body: text('body').notNull(), + bodyType: text('body_type', { enum: ['plain', 'html', 'md', 'webview'] }) + .notNull() + .default('plain'), /** URL gambar opsional untuk rich push / inbox. */ imageUrl: text('image_url'), /** word | contribution | suggestion | url | none */ diff --git a/src/shared/database/drizzle/schema/notifications.schema.ts b/src/shared/database/drizzle/schema/notifications.schema.ts index 1a79e9ac..930350c8 100644 --- a/src/shared/database/drizzle/schema/notifications.schema.ts +++ b/src/shared/database/drizzle/schema/notifications.schema.ts @@ -16,6 +16,9 @@ export const notifications = sqliteTable( type: text('type').notNull(), title: text('title').notNull(), body: text('body').notNull(), + bodyType: text('body_type', { enum: ['plain', 'html', 'md', 'webview'] }) + .notNull() + .default('plain'), /** URL gambar opsional (campaign rich push / inbox thumbnail). */ imageUrl: text('image_url'), targetKind: text('target_kind').notNull(),