Fix zod types and unseen icon not showing

This commit is contained in:
MartinBraquet
2026-03-10 16:22:44 +01:00
parent 1a2aa16645
commit dbf12a2ab2
14 changed files with 120 additions and 71 deletions

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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<string, any>
returns?: z.ZodType | Record<string, any>
/**
* 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<N extends APIPath> = (typeof API)[N]
export type APIParams<N extends APIPath> = z.input<APISchema<N>['props']>
export type ValidatedAPIParams<N extends APIPath> = z.output<APISchema<N>['props']>
export type APIResponse<N extends APIPath> =
APISchema<N> extends {
returns: Record<string, any>
}
? APISchema<N>['returns']
: void
/**
* A helper to extract either the Input (backend) or Output (frontend)
* while preserving the fallback logic for non-zod types.
*/
type ExtractAPIResult<N extends APIPath, TMode extends 'input' | 'output'> =
APISchema<N> extends {returns: z.ZodTypeAny}
? TMode extends 'input'
? z.input<APISchema<N>['returns']>
: z.output<APISchema<N>['returns']>
: APISchema<N> extends {returns: infer R}
? R
: void
// 1. Frontend: The "Output" (Dates)
export type APIResponse<N extends APIPath> = ExtractAPIResult<N, 'output'>
// 2. Backend: The "Input" (Strings/Dates)
export type APIBackendReturn<N extends APIPath> = ExtractAPIResult<N, 'input'>
// 3. Keep your wrapper using the Backend Return type
export type APIResponseOptionalContinue<N extends APIPath> =
| {continue: () => Promise<void>; result: APIResponse<N>}
| APIResponse<N>
| {continue: () => Promise<void>; result: APIBackendReturn<N>}
| APIBackendReturn<N>

View File

@@ -28,6 +28,8 @@ export const contentSchema: z.ZodType<JSONContent> = 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'),

View File

@@ -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

View File

@@ -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'
}

View File

@@ -92,12 +92,12 @@ export function selectFrom<
return db.from(table).select<string, TResult>(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)
}

View File

@@ -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<P extends APIPath>(path: P, params: APIParams<P>) {
return typedAPICall(path, params, null)
}
// export function unauthedApi<P extends APIPath>(path: P, params: APIParams<P>) {
// return typedAPICall(path, params, null)
// }
export const typedAPICall = <P extends APIPath>(
export async function typedAPICall<P extends APIPath>(
path: P,
params: APIParams<P>,
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 = <P extends APIPath>(
}
})
return baseApiCall({
url,
method: API[path].method,
params: newParams,
user,
}) as Promise<APIResponse<P>>
}
function appendQuery(url: string, props: Record<string, any>) {
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<P>
}
function appendQuery(url: string, props: Record<string, any>) {
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()}`
}

View File

@@ -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.

View File

@@ -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')

View File

@@ -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 <Tooltip text={toolTip} {...rest} suppressHydrationWarning />

View File

@@ -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<number, string> | undefined
Record<number, Date> | 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}`)
)

View File

@@ -34,7 +34,7 @@ export async function api<P extends APIPath>(path: P, params: APIParams<P> = {})
}
}
return typedAPICall(path, params, auth.currentUser)
return await typedAPICall(path, params, auth.currentUser)
}
function curriedAPI<P extends APIPath>(path: P) {

View File

@@ -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()