From dbf12a2ab243adce906e44e42d21b65f63756bb8 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Tue, 10 Mar 2026 16:22:44 +0100 Subject: [PATCH] Fix zod types and unseen icon not showing --- backend/api/src/get-channel-seen-time.ts | 7 ++- backend/shared/src/supabase/messages.ts | 3 +- common/src/api/schema.ts | 43 +++++++++---- common/src/api/zod-types.ts | 2 + common/src/chat-message.ts | 3 +- common/src/logger.ts | 2 + common/src/supabase/utils.ts | 4 +- common/src/util/api.ts | 67 +++++++++------------ docs/knowledge.md | 26 ++++++++ web/components/messaging/messages-icon.tsx | 2 +- web/components/widgets/datetime-tooltip.tsx | 4 +- web/hooks/use-private-messages.ts | 20 +++--- web/lib/api.ts | 2 +- web/pages/messages/index.tsx | 6 +- 14 files changed, 120 insertions(+), 71 deletions(-) diff --git a/backend/api/src/get-channel-seen-time.ts b/backend/api/src/get-channel-seen-time.ts index 35524c12..3a89a9d7 100644 --- a/backend/api/src/get-channel-seen-time.ts +++ b/backend/api/src/get-channel-seen-time.ts @@ -12,10 +12,13 @@ export const getLastSeenChannelTime: APIHandler<'get-channel-seen-time'> = async order by channel_id, created_time desc `, [channelIds, auth.uid], - (r) => [r.channel_id as number, r.created_time as string], + (r) => [r.channel_id, r.created_time] as [number, Date], ) - return unseens as [number, string][] + // When this hits the network, JSON.stringify() turns the Date into an ISO string. + // Then the zod schema in the endpoint definition casts it back to front-end Date + return unseens } + export const setChannelLastSeenTime: APIHandler<'set-channel-seen-time'> = async (props, auth) => { const pg = createSupabaseDirectClient() const {channelId} = props diff --git a/backend/shared/src/supabase/messages.ts b/backend/shared/src/supabase/messages.ts index aea2af7d..64b1f9a9 100644 --- a/backend/shared/src/supabase/messages.ts +++ b/backend/shared/src/supabase/messages.ts @@ -10,10 +10,11 @@ export type DbChatMessage = ChatMessage & { export const convertPrivateChatMessage = (row: Row<'private_user_messages'>) => { const message = convertSQLtoTS<'private_user_messages', DbChatMessage>(row, { + // TODO: do not convert, stick to Date everywhere (zod converts Date -> String -> Date between front and back end) created_time: tsToMillis as any, }) parseMessageObject(message) - return message + return message as ChatMessage } type MessageObject = { diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 2b915dcd..c815a15b 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -4,6 +4,7 @@ import { baseProfilesSchema, combinedProfileSchema, contentSchema, + dateSchema, zBoolean, } from 'common/api/zod-types' import {ChatMessage} from 'common/chat-message' @@ -20,7 +21,7 @@ import {arrify} from 'common/util/array' import {z} from 'zod' import {LikeData, ShipData} from './profile-types' -import {FullUser, HiddenProfile} from './user-types' +import {FullUser, HiddenProfile} from './user-types' // mqp: very unscientific, just balancing our willingness to accept load // mqp: very unscientific, just balancing our willingness to accept load // with user willingness to put up with stale data @@ -32,7 +33,7 @@ export const DEFAULT_CACHE_STRATEGY = 'public, max-age=5, stale-while-revalidate * Defines the structure and behavior of API endpoints including HTTP method, * authentication requirements, request/response schemas, and metadata. */ -type APIGenericSchema = { +export type APIGenericSchema = { /** * HTTP method for the endpoint * - GET: For data retrieval operations @@ -66,7 +67,7 @@ type APIGenericSchema = { * Response type definition (JSON serializable) * Used for TypeScript typing and API documentation generation */ - returns?: Record + returns?: z.ZodType | Record /** * Cache-Control header value @@ -880,7 +881,12 @@ export const API = (_apiTypeCheck = { props: z.object({ channelIds: z.array(z.coerce.number()).or(z.coerce.number()).transform(arrify), }), - returns: [] as [number, string][], + returns: z.array( + z.tuple([ + z.number(), // Channel ID + dateSchema, // This turns the ISO string into a JS Date object + ]), + ), summary: 'Get last seen times for one or more channels', tag: 'Messages', }, @@ -1239,13 +1245,26 @@ export type APISchema = (typeof API)[N] export type APIParams = z.input['props']> export type ValidatedAPIParams = z.output['props']> -export type APIResponse = - APISchema extends { - returns: Record - } - ? APISchema['returns'] - : void +/** + * A helper to extract either the Input (backend) or Output (frontend) + * while preserving the fallback logic for non-zod types. + */ +type ExtractAPIResult = + APISchema extends {returns: z.ZodTypeAny} + ? TMode extends 'input' + ? z.input['returns']> + : z.output['returns']> + : APISchema extends {returns: infer R} + ? R + : void +// 1. Frontend: The "Output" (Dates) +export type APIResponse = ExtractAPIResult + +// 2. Backend: The "Input" (Strings/Dates) +export type APIBackendReturn = ExtractAPIResult + +// 3. Keep your wrapper using the Backend Return type export type APIResponseOptionalContinue = - | {continue: () => Promise; result: APIResponse} - | APIResponse + | {continue: () => Promise; result: APIBackendReturn} + | APIBackendReturn diff --git a/common/src/api/zod-types.ts b/common/src/api/zod-types.ts index a7ef232b..d3390ec1 100644 --- a/common/src/api/zod-types.ts +++ b/common/src/api/zod-types.ts @@ -28,6 +28,8 @@ export const contentSchema: z.ZodType = z.lazy(() => ), ) +export const dateSchema = z.union([z.string(), z.date(), z.number()]).pipe(z.coerce.date()) + const genderType = z.string() // z.union([ // z.literal('male'), diff --git a/common/src/chat-message.ts b/common/src/chat-message.ts index d3088efe..b28fb4f7 100644 --- a/common/src/chat-message.ts +++ b/common/src/chat-message.ts @@ -6,8 +6,7 @@ export type ChatMessage = { userId: string channelId: string content: JSONContent - createdTime: number - createdTimeTs: number + createdTime: number // TODO: switch to Date visibility: ChatVisibility isEdited: boolean reactions: any diff --git a/common/src/logger.ts b/common/src/logger.ts index 900f6dae..084a5d63 100644 --- a/common/src/logger.ts +++ b/common/src/logger.ts @@ -1,4 +1,5 @@ import {IS_PROD} from 'common/envs/constants' +import {IS_LOCAL} from 'common/hosting/constants' /** * Log level severity types @@ -151,6 +152,7 @@ export function logPageView(path: string): void { * @returns Current log level threshold */ const currentLevel = (): LogLevel => { + if (IS_LOCAL) return 'debug' if (IS_PROD || process.env.NODE_ENV == 'production') return 'info' return 'debug' } diff --git a/common/src/supabase/utils.ts b/common/src/supabase/utils.ts index 8d499646..26ea221f 100644 --- a/common/src/supabase/utils.ts +++ b/common/src/supabase/utils.ts @@ -92,12 +92,12 @@ export function selectFrom< return db.from(table).select(query) } -export function millisToTs(millis: number | undefined) { +export function millisToTs(millis: number | undefined): string | undefined { if (!millis) return return new Date(millis).toISOString() } -export function tsToMillis(ts: string) { +export function tsToMillis(ts: string): number { return Date.parse(ts) } diff --git a/common/src/util/api.ts b/common/src/util/api.ts index 032f986f..a23a04ed 100644 --- a/common/src/util/api.ts +++ b/common/src/util/api.ts @@ -1,18 +1,21 @@ -import {API, APIParams, APIPath, APIResponse} from 'common/api/schema' +import {API, APIGenericSchema, APIParams, APIPath, APIResponse} from 'common/api/schema' import {APIError, getApiUrl} from 'common/api/utils' import {removeUndefinedProps} from 'common/util/object' import {User} from 'firebase/auth' import {forEach} from 'lodash' -export function unauthedApi

(path: P, params: APIParams

) { - return typedAPICall(path, params, null) -} +// export function unauthedApi

(path: P, params: APIParams

) { +// return typedAPICall(path, params, null) +// } -export const typedAPICall =

( +export async function typedAPICall

( path: P, params: APIParams

, user: User | null, -) => { +) { + const definition = API[path] as APIGenericSchema + const method = definition.method + // parse any params that should part of the path (like market/:id) const newParams: any = {} let url = getApiUrl(path) @@ -24,35 +27,6 @@ export const typedAPICall =

( } }) - return baseApiCall({ - url, - method: API[path].method, - params: newParams, - user, - }) as Promise> -} - -function appendQuery(url: string, props: Record) { - const [base, query] = url.split(/\?(.+)/) - const params = new URLSearchParams(query) - forEach(removeUndefinedProps(props ?? {}), (v, k) => { - if (Array.isArray(v)) { - v.forEach((item) => params.append(k, item)) - } else { - params.set(k, v) - } - }) - return `${base}?${params.toString()}` -} - -export async function baseApiCall(props: { - url: string - method: 'POST' | 'PUT' | 'GET' - params: any - user: User | null -}) { - const {url, method, params, user} = props - const actualUrl = method === 'POST' ? url : appendQuery(url, params) const headers: HeadersInit = { 'Content-Type': 'application/json', @@ -68,10 +42,29 @@ export async function baseApiCall(props: { }) // console.log('Request', req) return fetch(req).then(async (resp) => { - const json = (await resp.json()) as {[k: string]: any} + let json = (await resp.json()) as {[k: string]: any} + + // Use Zod to parse the JSON. This triggers the z.coerce.date() transformation. + if (definition.returns && typeof definition.returns.parse === 'function') { + json = definition.returns.parse(json) + } + if (!resp.ok) { throw new APIError(resp.status as any, json?.message, json?.details) } return json - }) + }) as APIResponse

+} + +function appendQuery(url: string, props: Record) { + const [base, query] = url.split(/\?(.+)/) + const params = new URLSearchParams(query) + forEach(removeUndefinedProps(props ?? {}), (v, k) => { + if (Array.isArray(v)) { + v.forEach((item) => params.append(k, item)) + } else { + params.set(k, v) + } + }) + return `${base}?${params.toString()}` } diff --git a/docs/knowledge.md b/docs/knowledge.md index e8d5adb4..a924f5ed 100644 --- a/docs/knowledge.md +++ b/docs/knowledge.md @@ -481,3 +481,29 @@ for (const id of betIds) { ... } ``` + +--- + +### Timestamps + +We use `Date` for timestamps everywhere, not strings or milliseconds. There are still a few non-date left-overs; we need +to migrate them to Date. + +**Database:** `TIMESTAMPTZ` (PostgreSQL standard) + +**TypeScript:** `Date` + +Postgres automatically converts the timestamp to Date. Nothing needed in the code. + +```typescript +// What we do: +type User = { + id: string + createdTime: Date +} +``` + +The only instance when Date is not good is that it is not serializable. So when moving between front and back end, we +use zod in the endpoint definition to switch from Date to string, then request sent, then back to Date. Another issue is +when JSON serializing to store in local storage. Storing the date does not need conversion as JSON.stringify() converts +Date to string, but loading from local storage requires conversion from string to Date. diff --git a/web/components/messaging/messages-icon.tsx b/web/components/messaging/messages-icon.tsx index 1a70e2b3..bbbedaf3 100644 --- a/web/components/messaging/messages-icon.tsx +++ b/web/components/messaging/messages-icon.tsx @@ -50,7 +50,7 @@ function InternalUnseenMessagesBubble(props: { }) { const {privateUser, className, bubbleClassName} = props - const {unseenChannels} = useUnseenPrivateMessageChannels(privateUser.id, false) + const {unseenChannels} = useUnseenPrivateMessageChannels(false) const pathName = usePathname() const {sendToBrowser} = getNotificationDestinationsForUser(privateUser, 'new_message') diff --git a/web/components/widgets/datetime-tooltip.tsx b/web/components/widgets/datetime-tooltip.tsx index 47af4212..4e6681dc 100644 --- a/web/components/widgets/datetime-tooltip.tsx +++ b/web/components/widgets/datetime-tooltip.tsx @@ -5,7 +5,7 @@ import {formatTime} from 'web/lib/util/time' import {Tooltip} from './tooltip' export function DateTimeTooltip(props: { - time: number + time: number | Date | string text?: string className?: string children: ReactNode @@ -15,7 +15,7 @@ export function DateTimeTooltip(props: { }) { const {time, text, ...rest} = props - const formattedTime = formatTime(time) + const formattedTime = formatTime(new Date(time)) const toolTip = text ? `${text} ${formattedTime}` : formattedTime return diff --git a/web/hooks/use-private-messages.ts b/web/hooks/use-private-messages.ts index 58994fda..1294155b 100644 --- a/web/hooks/use-private-messages.ts +++ b/web/hooks/use-private-messages.ts @@ -58,11 +58,11 @@ export function usePrivateMessages(channelId: number, limit: number, userId: str return {messages, fetchMessages, setMessages} } -export const useUnseenPrivateMessageChannels = (userId: string, ignorePageSeenTime: boolean) => { +export const useUnseenPrivateMessageChannels = (ignorePageSeenTime: boolean) => { const pathName = usePathname() - const lastSeenMessagesPageTime = useLastSeenMessagesPageTime() + const lastSeenMessagesPageTime = useLastSeenMessagesPageTime() // ms for now const [lastSeenChatTimeByChannelId, setLastSeenChatTimeByChannelId] = useState< - Record | undefined + Record | undefined >(undefined) const {data, refresh} = useAPIGetter( @@ -116,16 +116,20 @@ export const useUnseenPrivateMessageChannels = (userId: string, ignorePageSeenTi } }, [channels?.length]) + console.log({lastSeenChatTimeByChannelId}) + if (!lastSeenChatTimeByChannelId) return {unseenChannels: [], lastSeenChatTimeByChannelId: {}} + console.log('Got defined') const unseenChannels = channels.filter((channel) => { const channelId = channel.channel_id - const notifyAfterTime = - channels?.find((m) => m.channel_id === channelId)?.notify_after_time ?? '0' + const notifyAfterTime = new Date( + channels?.find((m) => m.channel_id === channelId)?.notify_after_time ?? '0', + ) - const lastSeenTime = lastSeenChatTimeByChannelId[channelId] ?? 0 - const lastSeenChatTime = notifyAfterTime > lastSeenTime ? notifyAfterTime : (lastSeenTime ?? 0) + const lastSeenTime = lastSeenChatTimeByChannelId[channelId] + const lastSeenChatTime = notifyAfterTime > lastSeenTime ? notifyAfterTime : lastSeenTime return ( - channel.last_updated_time > lastSeenChatTime && + new Date(channel.last_updated_time) > lastSeenChatTime && (ignorePageSeenTime || tsToMillis(channel.last_updated_time) > lastSeenMessagesPageTime) && !pathName?.endsWith(`/messages/${channelId}`) ) diff --git a/web/lib/api.ts b/web/lib/api.ts index 1b5f3614..fa6a2795 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -34,7 +34,7 @@ export async function api

(path: P, params: APIParams

= {}) } } - return typedAPICall(path, params, auth.currentUser) + return await typedAPICall(path, params, auth.currentUser) } function curriedAPI

(path: P) { diff --git a/web/pages/messages/index.tsx b/web/pages/messages/index.tsx index dea4890c..f2bfe221 100644 --- a/web/pages/messages/index.tsx +++ b/web/pages/messages/index.tsx @@ -54,7 +54,7 @@ export function MessagesContent(props: {currentUser: User}) { const {currentUser} = props const t = useT() const {channels, memberIdsByChannelId} = useSortedPrivateMessageMemberships(currentUser.id) - const {lastSeenChatTimeByChannelId} = useUnseenPrivateMessageChannels(currentUser.id, true) + const {lastSeenChatTimeByChannelId} = useUnseenPrivateMessageChannels(true) const lastMessages = useLastPrivateMessages(currentUser.id) return ( @@ -91,13 +91,13 @@ export const MessageChannelRow = (props: { otherUserIds: string[] currentUser: User channel: PrivateMessageChannel - lastSeenTime: string + lastSeenTime: Date | undefined lastMessage?: ChatMessage }) => { const {otherUserIds, lastSeenTime, currentUser, channel, lastMessage} = props const channelId = channel.channel_id const otherUsers = useUsersInStore(otherUserIds, `${channelId}`, 100) - const unseen = (lastMessage?.createdTimeTs ?? '0') > lastSeenTime + const unseen = lastSeenTime ? new Date(lastMessage?.createdTime ?? 0) > lastSeenTime : false const numOthers = otherUsers?.length ?? 0 const t = useT()