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
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS content_revisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"contentId" uuid NOT NULL REFERENCES contents(id) ON DELETE CASCADE,
version integer NOT NULL,
snapshot jsonb NOT NULL,
"createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_content_revisions_content_version UNIQUE ("contentId", version)
);

INSERT INTO content_revisions ("contentId", version, snapshot)
SELECT
id,
COALESCE(CASE WHEN metadata->>'version' ~ '^[0-9]+$' THEN (metadata->>'version')::integer END, 1),
jsonb_build_object(
'id', id,
'title', title,
'contentType', "contentType",
'category', category,
'tags', tags,
'content', content,
'metadata', COALESCE(metadata, '{}'::jsonb),
'version', COALESCE(CASE WHEN metadata->>'version' ~ '^[0-9]+$' THEN (metadata->>'version')::integer END, 1),
'updatedAt', "updatedAt"
)
FROM contents
ON CONFLICT ("contentId", version) DO NOTHING;

CREATE TABLE IF NOT EXISTS content_delivery_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"contentId" uuid NULL REFERENCES contents(id) ON DELETE SET NULL,
"eventType" varchar(20) NOT NULL,
"createdAt" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_content_delivery_events_type_created
ON content_delivery_events ("eventType", "createdAt");
CREATE INDEX IF NOT EXISTS idx_content_delivery_events_content_created
ON content_delivery_events ("contentId", "createdAt");
4 changes: 4 additions & 0 deletions microservices/content-service/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { ModerationAction } from './entities/moderation-action.entity.js';
import { ModerationQueue } from './entities/moderation-queue.entity.js';
import { FeaturedContent } from './entities/featured-content.entity.js';
import { ContentFile } from './entities/content-file.entity.js';
import { ContentRevision } from './entities/content-revision.entity.js';
import { ContentDeliveryEvent } from './entities/content-delivery-event.entity.js';

import { ContentModule } from './content/content.module.js';
import { SubmissionModule } from './submission/submission.module.js';
Expand Down Expand Up @@ -43,6 +45,8 @@ import databaseConfig from './config/database.config.js';
ModerationQueue,
FeaturedContent,
ContentFile,
ContentRevision,
ContentDeliveryEvent,
],
synchronize: configService.get<string>('NODE_ENV') !== 'production',
logging: configService.get<string>('NODE_ENV') === 'development',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { BadRequestException, Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ContentType } from '../entities/content.entity.js';
import { ContentDeliveryService } from './content-delivery.service.js';

@Controller('content/delivery')
export class ContentDeliveryController {
constructor(private readonly deliveryService: ContentDeliveryService) {}

@Get('manifest')
getManifest(
@Query('updatedSince') updatedSince?: string,
@Query('contentType') contentType?: string,
) {
if (contentType && !Object.values(ContentType).includes(contentType as ContentType)) {
throw new BadRequestException('contentType is invalid');
}
return this.deliveryService.getManifest({ updatedSince, contentType: contentType as ContentType | undefined });
}

@Get('bundle')
getBundle(@Query('ids') ids?: string) {
return this.deliveryService.getBundle(this.parseIds(ids));
}

@Get('prefetch')
prefetch(@Query('ids') ids?: string) {
return this.deliveryService.getBundle(this.parseIds(ids), true);
}

@Get('analytics')
getAnalytics(@Query('since') since?: string) {
return this.deliveryService.getAnalytics(since);
}

@Get(':id')
getContent(
@Param('id', ParseUUIDPipe) id: string,
@Query('sinceVersion') sinceVersion?: string,
) {
if (sinceVersion !== undefined && !/^\d+$/.test(sinceVersion)) {
throw new BadRequestException('sinceVersion must be a positive integer');
}
return this.deliveryService.getContent(id, sinceVersion === undefined ? undefined : Number(sinceVersion));
}

private parseIds(ids?: string): string[] {
if (!ids) {
throw new BadRequestException('ids is required');
}
return ids.split(',');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { BadRequestException } from '@nestjs/common';
import { ContentDeliveryService } from './content-delivery.service.js';
import { ContentStatus, ContentType } from '../entities/content.entity.js';
import { ContentDeliveryEventType } from '../entities/content-delivery-event.entity.js';

describe('ContentDeliveryService', () => {
const content = {
id: '8b4c1c98-b427-4ad2-97b0-6f27f3bba7ad',
title: 'Sample',
contentType: ContentType.ARTICLE,
category: 'guides',
tags: ['intro'],
content: { body: 'new text' },
metadata: { version: 2 },
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
let service: ContentDeliveryService;
let contentQuery: any;
let revisionRepository: any;
let eventRepository: any;

beforeEach(() => {
contentQuery = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([content]),
getOne: jest.fn().mockResolvedValue(content),
};
revisionRepository = { findOne: jest.fn(), create: jest.fn((value) => value) };
eventRepository = {
create: jest.fn((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
service = new ContentDeliveryService(
{ createQueryBuilder: jest.fn(() => contentQuery) } as any,
revisionRepository,
eventRepository,
);
});

it('returns a lightweight manifest with content hashes', async () => {
const result = await service.getManifest();
expect(result.items[0]).toEqual(expect.objectContaining({ id: content.id, version: 2 }));
expect(result.items[0].etag).toMatch(/^[a-f0-9]{64}$/);
expect(result.items[0]).not.toHaveProperty('content');
expect(eventRepository.save).toHaveBeenCalledWith({ eventType: ContentDeliveryEventType.MANIFEST, contentId: null });
});

it('returns only a JSON patch when the requested base revision exists', async () => {
revisionRepository.findOne
.mockResolvedValueOnce({ snapshot: { content: { body: 'new text' } } })
.mockResolvedValueOnce({ snapshot: { content: { body: 'old text' } } });

const result = await service.getContent(content.id, 1);
expect(result).toEqual(expect.objectContaining({
mode: 'delta',
delta: [{ op: 'replace', path: '/content/body', value: 'new text' }],
}));
expect(eventRepository.save).toHaveBeenCalledWith({ eventType: ContentDeliveryEventType.DELTA, contentId: content.id });
});

it('falls back to a full payload if a delta base is unavailable', async () => {
revisionRepository.findOne.mockResolvedValueOnce({ contentId: content.id, version: 2 }).mockResolvedValueOnce(null);
const result = await service.getContent(content.id, 1);
expect(result).toEqual(expect.objectContaining({ mode: 'full', data: expect.objectContaining({ content: content.content }) }));
});

it('rejects invalid bundle IDs and versions', async () => {
await expect(service.getBundle(['not-a-uuid'])).rejects.toBeInstanceOf(BadRequestException);
await expect(service.getContent(content.id, 0)).rejects.toBeInstanceOf(BadRequestException);
});

it('returns requested bundle items in request order and tracks prefetch events', async () => {
const result = await service.getBundle([content.id], true);
expect(result.items[0].id).toBe(content.id);
expect(eventRepository.save).toHaveBeenCalledWith({ eventType: ContentDeliveryEventType.PREFETCH, contentId: content.id });
});
});
204 changes: 204 additions & 0 deletions microservices/content-service/src/content/content-delivery.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { createHash } from 'node:crypto';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository, SelectQueryBuilder } from 'typeorm';
import { Content, ContentStatus, ContentType } from '../entities/content.entity.js';
import {
ContentDeliveryEvent,
ContentDeliveryEventType,
} from '../entities/content-delivery-event.entity.js';
import { ContentRevision } from '../entities/content-revision.entity.js';

type JsonPatchOperation = {
op: 'add' | 'remove' | 'replace';
path: string;
value?: unknown;
};

@Injectable()
export class ContentDeliveryService {
constructor(
@InjectRepository(Content) private readonly contentRepository: Repository<Content>,
@InjectRepository(ContentRevision) private readonly revisionRepository: Repository<ContentRevision>,
@InjectRepository(ContentDeliveryEvent)
private readonly eventRepository: Repository<ContentDeliveryEvent>,
) {}

async getManifest(options: { updatedSince?: string; contentType?: ContentType } = {}) {
const query = this.publishedQuery();
if (options.updatedSince) {
const timestamp = new Date(options.updatedSince);
if (Number.isNaN(timestamp.getTime())) {
throw new BadRequestException('updatedSince must be a valid date');
}
query.andWhere('content.updatedAt > :updatedSince', { updatedSince: timestamp });
}
if (options.contentType) {
query.andWhere('content.contentType = :contentType', { contentType: options.contentType });
}

const contents = await query.orderBy('content.updatedAt', 'ASC').getMany();
await this.track(ContentDeliveryEventType.MANIFEST);

return {
generatedAt: new Date().toISOString(),
items: contents.map((content) => {
const payload = this.toPayload(content);
return {
id: content.id,
title: content.title,
contentType: content.contentType,
category: content.category,
tags: content.tags,
version: payload.version,
updatedAt: content.updatedAt,
etag: this.hash(payload),
};
}),
};
}

async getContent(id: string, sinceVersion?: number) {
if (sinceVersion !== undefined && (!Number.isInteger(sinceVersion) || sinceVersion < 1)) {
throw new BadRequestException('sinceVersion must be a positive integer');
}

const content = await this.publishedQuery().andWhere('content.id = :id', { id }).getOne();
if (!content) {
throw new NotFoundException(`Public content with ID ${id} not found`);
}

const payload = this.toPayload(content);
const latestRevision = await this.revisionRepository.findOne({
where: { contentId: id, version: payload.version },
});

if (sinceVersion !== undefined && latestRevision && sinceVersion === payload.version) {
await this.track(ContentDeliveryEventType.LOAD, id);
return { id, version: payload.version, mode: 'not-modified' as const };
}

if (sinceVersion !== undefined && sinceVersion < payload.version && latestRevision) {
const baseRevision = await this.revisionRepository.findOne({
where: { contentId: id, version: sinceVersion },
});
if (baseRevision) {
await this.track(ContentDeliveryEventType.DELTA, id);
return {
id,
version: payload.version,
updatedAt: content.updatedAt,
mode: 'delta' as const,
delta: this.diff(baseRevision.snapshot, latestRevision.snapshot),
};
}
}

await this.track(ContentDeliveryEventType.LOAD, id);
return { id, version: payload.version, updatedAt: content.updatedAt, mode: 'full' as const, data: payload };
}

async getBundle(ids: string[], prefetch = false) {
const uniqueIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
if (uniqueIds.length === 0 || uniqueIds.length > 100 || uniqueIds.some((id) => !this.isUuid(id))) {
throw new BadRequestException('Provide between 1 and 100 valid content IDs');
}

const contents = await this.publishedQuery().andWhere('content.id IN (:...ids)', { ids: uniqueIds }).getMany();
const contentById = new Map(contents.map((content) => [content.id, content]));
const items = uniqueIds.flatMap((id) => {
const content = contentById.get(id);
return content ? [{ id, version: this.toPayload(content).version, data: this.toPayload(content) }] : [];
});

await Promise.all(
items.map(({ id }) => this.track(prefetch ? ContentDeliveryEventType.PREFETCH : ContentDeliveryEventType.BUNDLE, id)),
);

return { items, missingIds: uniqueIds.filter((id) => !contentById.has(id)) };
}

async getAnalytics(since?: string) {
const query: SelectQueryBuilder<ContentDeliveryEvent> = this.eventRepository
.createQueryBuilder('event')
.select('event.eventType', 'eventType')
.addSelect('COUNT(*)', 'count')
.groupBy('event.eventType');

if (since) {
const timestamp = new Date(since);
if (Number.isNaN(timestamp.getTime())) {
throw new BadRequestException('since must be a valid date');
}
query.where('event.createdAt >= :since', { since: timestamp });
}

const rows = await query.getRawMany<{ eventType: string; count: string }>();
return rows.map((row) => ({ eventType: row.eventType, count: Number(row.count) }));
}

private publishedQuery(): SelectQueryBuilder<Content> {
return this.contentRepository
.createQueryBuilder('content')
.where('content.isPublic = :isPublic', { isPublic: true })
.andWhere('content.status = :status', { status: ContentStatus.PUBLISHED });
}

private toPayload(content: Content) {
const metadataVersion = content.metadata?.version;
return {
id: content.id,
title: content.title,
contentType: content.contentType,
category: content.category,
tags: content.tags,
content: content.content,
metadata: content.metadata,
version: typeof metadataVersion === 'number' && Number.isInteger(metadataVersion) ? metadataVersion : 1,
updatedAt: content.updatedAt,
};
}

private async track(eventType: ContentDeliveryEventType, contentId: string | null = null): Promise<void> {
await this.eventRepository.save(this.eventRepository.create({ eventType, contentId }));
}

private hash(value: unknown): string {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}

private diff(previous: unknown, next: unknown, path = ''): JsonPatchOperation[] {
if (this.isObject(previous) && this.isObject(next)) {
const operations: JsonPatchOperation[] = [];
for (const key of Object.keys(previous)) {
const escapedKey = key.replace(/~/g, '~0').replace(/\//g, '~1');
const childPath = `${path}/${escapedKey}`;
if (!(key in next)) {
operations.push({ op: 'remove', path: childPath });
} else {
operations.push(...this.diff(previous[key], next[key], childPath));
}
}
for (const key of Object.keys(next)) {
if (!(key in previous)) {
const escapedKey = key.replace(/~/g, '~0').replace(/\//g, '~1');
operations.push({ op: 'add', path: `${path}/${escapedKey}`, value: next[key] });
}
}
return operations;
}

if (JSON.stringify(previous) !== JSON.stringify(next)) {
return [{ op: path ? 'replace' : 'replace', path, value: next }];
}
return [];
}

private isObject(value: unknown): value is Record<string, any> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}

private isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
}
Loading
Loading