forked from boos/BoosAPI
fix: unify external db datetime behavior
This commit is contained in:
@@ -117,6 +117,63 @@ describe('site announcements routes', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes postgres Date values for managed timestamps before filtering unread rows', async () => {
|
||||
const routesModule = await import('./siteAnnouncements.js');
|
||||
|
||||
const rows = routesModule.buildSiteAnnouncementsResponseRows([
|
||||
{
|
||||
id: 1,
|
||||
siteId: 7,
|
||||
platform: 'sub2api',
|
||||
sourceKey: 'announcement:read',
|
||||
title: 'Read row',
|
||||
content: 'Already read',
|
||||
level: 'info',
|
||||
sourceUrl: null,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
upstreamCreatedAt: '2026-03-10T04:30:08.200Z',
|
||||
upstreamUpdatedAt: '2026-03-10T04:30:08.200Z',
|
||||
firstSeenAt: new Date('2026-03-19T22:41:26.000Z') as never,
|
||||
lastSeenAt: new Date('2026-03-19T22:41:26.000Z') as never,
|
||||
readAt: new Date('2026-03-19T22:45:00.000Z') as never,
|
||||
dismissedAt: null,
|
||||
rawPayload: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
siteId: 8,
|
||||
platform: 'new-api',
|
||||
sourceKey: 'announcement:unread',
|
||||
title: 'Unread row',
|
||||
content: 'Still unread',
|
||||
level: 'info',
|
||||
sourceUrl: null,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
upstreamCreatedAt: null,
|
||||
upstreamUpdatedAt: null,
|
||||
firstSeenAt: new Date('2026-03-19T22:55:27.000Z') as never,
|
||||
lastSeenAt: new Date('2026-03-19T22:55:27.000Z') as never,
|
||||
readAt: null,
|
||||
dismissedAt: null,
|
||||
rawPayload: null,
|
||||
},
|
||||
] as Array<typeof schema.siteAnnouncements.$inferSelect>, {
|
||||
read: 'false',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
firstSeenAt: '2026-03-20 06:55:27',
|
||||
lastSeenAt: '2026-03-20 06:55:27',
|
||||
readAt: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks one announcement as read and then marks all as read', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub Site',
|
||||
|
||||
@@ -2,24 +2,82 @@ import { FastifyInstance } from 'fastify';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
|
||||
import { syncSiteAnnouncements } from '../../services/siteAnnouncementService.js';
|
||||
|
||||
type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect;
|
||||
type SiteAnnouncementsResponseFilters = {
|
||||
read?: string;
|
||||
status?: string;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
function parseTimeValue(input?: string | null): number | null {
|
||||
function formatDateTimePartsInTimeZone(value: Date, timeZone: string): string | null {
|
||||
if (Number.isNaN(value.getTime())) return null;
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = formatter.formatToParts(value);
|
||||
const lookup = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const year = lookup.get('year');
|
||||
const month = lookup.get('month');
|
||||
const day = lookup.get('day');
|
||||
const hour = lookup.get('hour');
|
||||
const minute = lookup.get('minute');
|
||||
const second = lookup.get('second');
|
||||
if (!year || !month || !day || !hour || !minute || !second) return null;
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
function normalizeManagedDateTimeValue(input: unknown, timeZone = getResolvedTimeZone()): string | null {
|
||||
if (input == null) return null;
|
||||
if (input instanceof Date) {
|
||||
return formatDateTimePartsInTimeZone(input, timeZone);
|
||||
}
|
||||
const raw = String(input).trim();
|
||||
return raw || null;
|
||||
}
|
||||
|
||||
function parseTimeValue(input?: unknown): number | null {
|
||||
if (input instanceof Date) {
|
||||
return Number.isNaN(input.getTime()) ? null : input.getTime();
|
||||
}
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) return null;
|
||||
const parsed = Date.parse(raw.includes('T') ? raw : raw.replace(' ', 'T') + 'Z');
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function hasDateTimeValue(input: unknown): boolean {
|
||||
if (input instanceof Date) {
|
||||
return !Number.isNaN(input.getTime());
|
||||
}
|
||||
return typeof input === 'string' && input.trim().length > 0;
|
||||
}
|
||||
|
||||
export function normalizeSiteAnnouncementRowForResponse(row: SiteAnnouncementRow, timeZone = getResolvedTimeZone()) {
|
||||
return {
|
||||
...row,
|
||||
firstSeenAt: normalizeManagedDateTimeValue((row as { firstSeenAt?: unknown }).firstSeenAt, timeZone),
|
||||
lastSeenAt: normalizeManagedDateTimeValue((row as { lastSeenAt?: unknown }).lastSeenAt, timeZone),
|
||||
readAt: normalizeManagedDateTimeValue((row as { readAt?: unknown }).readAt, timeZone),
|
||||
dismissedAt: normalizeManagedDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt, timeZone),
|
||||
};
|
||||
}
|
||||
|
||||
function applyReadFilter(rows: SiteAnnouncementRow[], read?: string): SiteAnnouncementRow[] {
|
||||
if (read === 'true') {
|
||||
return rows.filter((row) => typeof row.readAt === 'string' && row.readAt.length > 0);
|
||||
return rows.filter((row) => hasDateTimeValue((row as { readAt?: unknown }).readAt));
|
||||
}
|
||||
if (read === 'false') {
|
||||
return rows.filter((row) => !row.readAt);
|
||||
return rows.filter((row) => !hasDateTimeValue((row as { readAt?: unknown }).readAt));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -29,23 +87,31 @@ function applyStatusFilter(rows: SiteAnnouncementRow[], status?: string): SiteAn
|
||||
if (!normalized) return rows;
|
||||
const now = Date.now();
|
||||
if (normalized === 'dismissed') {
|
||||
return rows.filter((row) => typeof row.dismissedAt === 'string' && row.dismissedAt.length > 0);
|
||||
return rows.filter((row) => hasDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt));
|
||||
}
|
||||
if (normalized === 'expired') {
|
||||
return rows.filter((row) => !row.dismissedAt && (() => {
|
||||
const endsAt = parseTimeValue(row.endsAt);
|
||||
const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt);
|
||||
return endsAt !== null && endsAt < now;
|
||||
})());
|
||||
}
|
||||
if (normalized === 'active') {
|
||||
return rows.filter((row) => !row.dismissedAt && (() => {
|
||||
const endsAt = parseTimeValue(row.endsAt);
|
||||
const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt);
|
||||
return endsAt === null || endsAt >= now;
|
||||
})());
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildSiteAnnouncementsResponseRows(
|
||||
rows: SiteAnnouncementRow[],
|
||||
options?: SiteAnnouncementsResponseFilters,
|
||||
) {
|
||||
const normalizedRows = rows.map((row) => normalizeSiteAnnouncementRowForResponse(row, options?.timeZone));
|
||||
return applyStatusFilter(applyReadFilter(normalizedRows, options?.read), options?.status);
|
||||
}
|
||||
|
||||
export async function siteAnnouncementsRoutes(app: FastifyInstance) {
|
||||
app.get<{
|
||||
Querystring: {
|
||||
@@ -76,7 +142,10 @@ export async function siteAnnouncementsRoutes(app: FastifyInstance) {
|
||||
? await base.where(and(...filters)).orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all()
|
||||
: await base.orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all();
|
||||
|
||||
const filtered = applyStatusFilter(applyReadFilter(rows, request.query.read), request.query.status);
|
||||
const filtered = buildSiteAnnouncementsResponseRows(rows, {
|
||||
read: request.query.read,
|
||||
status: request.query.status,
|
||||
});
|
||||
return filtered.slice(offset, offset + limit);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user