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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) });
Expand Down
40 changes: 40 additions & 0 deletions src/modules/activity/__tests__/e2e/v1/activity.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
93 changes: 93 additions & 0 deletions src/modules/activity/__tests__/unit/announcement-feed.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }
).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<string, unknown> }
).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: '<h1>Halo</h1>',
bodyType: 'html',
}),
targetId: 'ann3',
};
const item = await (
repo as unknown as { buildItem: (r: unknown) => Record<string, unknown> }
).buildItem(row);
expect((item['announcement'] as Record<string, unknown>)['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<string, unknown> }
).buildItem(row);
expect((item['announcement'] as Record<string, unknown>)['bodyType']).toBe('plain');
});
});
2 changes: 2 additions & 0 deletions src/modules/activity/domain/entities/activity-item.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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':
Expand Down
3 changes: 3 additions & 0 deletions src/modules/activity/presentation/v1/activity.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading