Merge pull request #202 from cita-777/codex/site-announcement-postgres-time-fix

[codex] unify external db datetime behavior
This commit is contained in:
Cita
2026-03-20 15:49:13 +08:00
committed by GitHub
7 changed files with 176 additions and 43 deletions
@@ -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',
+77 -8
View File
@@ -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);
});