Release 202506151318

This commit is contained in:
pluja
2025-06-15 13:18:22 +00:00
parent effb6689d7
commit f3c9b92ddb
20 changed files with 159 additions and 77 deletions

48
web/package-lock.json generated
View File

@@ -2532,9 +2532,9 @@
} }
}, },
"node_modules/@eslint/config-array/node_modules/brace-expansion": { "node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2603,9 +2603,9 @@
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7196,9 +7196,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9325,9 +9325,9 @@
} }
}, },
"node_modules/eslint-plugin-import/node_modules/brace-expansion": { "node_modules/eslint-plugin-import/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9399,9 +9399,9 @@
} }
}, },
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9476,9 +9476,9 @@
} }
}, },
"node_modules/eslint/node_modules/brace-expansion": { "node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -10379,9 +10379,9 @@
} }
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -11671,9 +11671,9 @@
} }
}, },
"node_modules/jake/node_modules/brace-expansion": { "node_modules/jake/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `userAgent` on the `PushSubscription` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "PushSubscription" DROP COLUMN "userAgent";

View File

@@ -687,8 +687,6 @@ model PushSubscription {
p256dh String p256dh String
/// Authentication secret /// Authentication secret
auth String auth String
/// To identify different devices
userAgent String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -1,3 +1,4 @@
import { ActionError } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
@@ -32,7 +33,6 @@ export const notificationActions = {
endpoint: z.string(), endpoint: z.string(),
p256dhKey: z.string(), p256dhKey: z.string(),
authKey: z.string(), authKey: z.string(),
userAgent: z.string().optional(),
}), }),
handler: async (input, context) => { handler: async (input, context) => {
await prisma.pushSubscription.upsert({ await prisma.pushSubscription.upsert({
@@ -43,14 +43,12 @@ export const notificationActions = {
update: { update: {
p256dh: input.p256dhKey, p256dh: input.p256dhKey,
auth: input.authKey, auth: input.authKey,
userAgent: input.userAgent,
}, },
create: { create: {
userId: context.locals.user.id, userId: context.locals.user.id,
endpoint: input.endpoint, endpoint: input.endpoint,
p256dh: input.p256dhKey, p256dh: input.p256dhKey,
auth: input.authKey, auth: input.authKey,
userAgent: input.userAgent,
}, },
}) })
}, },
@@ -58,7 +56,7 @@ export const notificationActions = {
unsubscribe: defineProtectedAction({ unsubscribe: defineProtectedAction({
accept: 'json', accept: 'json',
permissions: 'user', permissions: 'guest',
input: z.object({ input: z.object({
endpoint: z.string().optional(), endpoint: z.string().optional(),
}), }),
@@ -66,16 +64,21 @@ export const notificationActions = {
if (input.endpoint) { if (input.endpoint) {
await prisma.pushSubscription.deleteMany({ await prisma.pushSubscription.deleteMany({
where: { where: {
userId: context.locals.user.id, userId: context.locals.user?.id ?? undefined,
endpoint: input.endpoint, endpoint: input.endpoint,
}, },
}) })
} else { } else if (context.locals.user) {
await prisma.pushSubscription.deleteMany({ await prisma.pushSubscription.deleteMany({
where: { where: {
userId: context.locals.user.id, userId: context.locals.user.id,
}, },
}) })
} else {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Endpoint is required when user is not logged in.',
})
} }
}, },
}), }),

View File

@@ -107,12 +107,8 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<DynamicFavicon /> <DynamicFavicon />
<!-- Components --> <ClientRouter />
{
!Astro.url.pathname.startsWith('/admin') && (
<ClientRouter />
) /* Disable to prevent bugs in important admin forms */
}
<LoadingIndicator color="green" /> <LoadingIndicator color="green" />
<TailwindJsPluggin /> <TailwindJsPluggin />
{htmx && <HtmxScript />} {htmx && <HtmxScript />}

View File

@@ -71,12 +71,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
'Failed to fetch comments', 'Failed to fetch comments',
async () => async () =>
await prisma.comment.findMany( await prisma.comment.findMany(
makeCommentsNestedQuery({ await makeCommentsNestedQuery({
depth: MAX_COMMENT_DEPTH, depth: MAX_COMMENT_DEPTH,
user, user,
showPending: params.showPending, showPending: params.showPending,
serviceId: service.id, serviceId: service.id,
sort: params.sort, sort: params.sort,
highlightedCommentId: params.comment,
}) })
), ),
[], [],

View File

@@ -16,7 +16,6 @@ type Props = HTMLAttributes<'div'> & {
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{ pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
select: { select: {
endpoint: true endpoint: true
userAgent: true
} }
}>[] }>[]
} }

View File

@@ -27,6 +27,12 @@ if (!Astro.locals.user) return
} }
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
// NOTE: Disable sse: events when user is not logged in
if (!document.body.hasAttribute('data-is-logged-in')) {
stopServerEventsListener()
return
}
try { try {
const data = JSON.parse(event.data as string) const data = JSON.parse(event.data as string)

View File

@@ -4,6 +4,7 @@
<script> <script>
import { registerSW } from 'virtual:pwa-register' import { registerSW } from 'virtual:pwa-register'
import { unsubscribeFromPushNotifications } from '../lib/client/clientPushNotifications'
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[] const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
@@ -33,7 +34,7 @@
function shouldSkipAutoReload() { function shouldSkipAutoReload() {
const currentPath = window.location.pathname const currentPath = window.location.pathname
const isErrorPage = document.querySelector('[data-is-error-page]') !== null const isErrorPage = document.body.hasAttribute('data-is-error-page')
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route) return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
} }
@@ -48,4 +49,11 @@
window.addEventListener('beforeinstallprompt', (event) => { window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault() event.preventDefault()
}) })
document.addEventListener('astro:page-load', async () => {
if (!document.body.hasAttribute('data-is-logged-in')) {
await unsubscribeFromPushNotifications()
window.__SW_REGISTRATION__?.unregister()
}
})
</script> </script>

View File

@@ -1,10 +1,10 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { differenceInDays } from 'date-fns'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus' import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { formatDaysAgo } from '../lib/timeAgo'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -27,15 +27,6 @@ type Props = {
} }
const { service } = Astro.props const { service } = Astro.props
function formatApprovedAt(approvedAt: Date | null) {
if (!approvedAt) return 'less than 15 days ago'
const days = differenceInDays(new Date(), approvedAt)
if (days === 0) return 'today'
if (days === 1) return 'yesterday'
return `${days.toLocaleString()} days ago`
}
--- ---
{ {
@@ -73,7 +64,7 @@ function formatApprovedAt(approvedAt: Date | null) {
) : service.isRecentlyApproved ? ( ) : service.isRecentlyApproved ? (
<div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400"> <div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
This service was approved This service was approved
{formatApprovedAt(service.approvedAt)} {service.approvedAt ? formatDaysAgo(service.approvedAt) : 'less than 15 days ago'}
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with {service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
caution. caution.
<a <a

View File

@@ -81,7 +81,8 @@ const announcement = await Astro.locals.banners.try(
</head> </head>
<body <body
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)} class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
data-is-error-page={isErrorPage} data-is-error-page={isErrorPage ? '' : undefined}
data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
> >
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />} {announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
<Header <Header

View File

@@ -7,7 +7,7 @@ import { kycLevels } from '../constants/kycLevels'
import { serviceVisibilitiesById } from '../constants/serviceVisibility' import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus' import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
import { formatDateShort } from './timeAgo' import { formatDaysAgo } from './timeAgo'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -199,7 +199,7 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
links: [], links: [],
customize: (service) => ({ customize: (service) => ({
show: service.isRecentlyApproved, show: service.isRecentlyApproved,
description: `Approved on KYCnot.me ${formatDateShort(service.approvedAt ?? service.createdAt)}. Proceed with caution.`, description: `Approved on KYCnot.me less than 15 days ago${service.approvedAt ? ` (${formatDaysAgo(service.approvedAt)})` : ''}. Proceed with caution.`,
}), }),
}, },
{ {

View File

@@ -7,11 +7,15 @@ export function supportsBrowserNotifications() {
} }
export function isBrowserNotificationsEnabled() { export function isBrowserNotificationsEnabled() {
return ( const browserNotificationsEnabled = typedLocalStorage.browserNotificationsEnabled.get()
supportsBrowserNotifications() && if (!browserNotificationsEnabled) return false
Notification.permission === 'granted' &&
typedLocalStorage.browserNotificationsEnabled.get() if (!document.body.hasAttribute('data-is-logged-in')) {
) typedLocalStorage.browserNotificationsEnabled.set(false)
return false
}
return supportsBrowserNotifications() && Notification.permission === 'granted'
} }
export async function enableBrowserNotifications(): Promise<SafeResult> { export async function enableBrowserNotifications(): Promise<SafeResult> {

View File

@@ -5,7 +5,6 @@ import type { actions } from 'astro:actions'
type ServerSubscription = { type ServerSubscription = {
endpoint: string endpoint: string
userAgent: string | null
} }
export type SafeResult = export type SafeResult =
@@ -45,7 +44,6 @@ export async function subscribeToPushNotifications(vapidPublicKey: string): Prom
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
userAgent: navigator.userAgent,
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '', p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '', authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>), } satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
@@ -131,13 +129,7 @@ export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubsc
const currentSubscription = await getCurrentSubscription() const currentSubscription = await getCurrentSubscription()
if (!currentSubscription || serverSubscriptions.length === 0) return false if (!currentSubscription || serverSubscriptions.length === 0) return false
const currentEndpoint = currentSubscription.endpoint return serverSubscriptions.some((sub) => sub.endpoint === currentSubscription.endpoint)
const currentUserAgent = navigator.userAgent
return serverSubscriptions.some(
(sub) =>
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
)
} }
function urlB64ToUint8Array(base64String: string) { function urlB64ToUint8Array(base64String: string) {
@@ -183,5 +175,5 @@ export function parsePushSubscriptions(subscriptionsAsString: string | undefined
function isServerSubscription(subscription: unknown): subscription is ServerSubscription { function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
if (typeof subscription !== 'object' || subscription === null) return false if (typeof subscription !== 'object' || subscription === null) return false
const s = subscription as Record<string, unknown> const s = subscription as Record<string, unknown>
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null) return typeof s.endpoint === 'string'
} }

View File

@@ -1,5 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { prisma } from './prisma'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
export const MAX_COMMENT_DEPTH = 12 export const MAX_COMMENT_DEPTH = 12
@@ -75,12 +77,13 @@ export type CommentWithRepliesPopulated = CommentWithReplies<{
export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest') export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
export type CommentSortOption = z.infer<typeof commentSortSchema> export type CommentSortOption = z.infer<typeof commentSortSchema>
export function makeCommentsNestedQuery({ export async function makeCommentsNestedQuery({
depth = 0, depth = 0,
user, user,
showPending, showPending,
serviceId, serviceId,
sort, sort,
highlightedCommentId,
}: { }: {
depth?: number depth?: number
user: Prisma.UserGetPayload<{ user: Prisma.UserGetPayload<{
@@ -91,6 +94,7 @@ export function makeCommentsNestedQuery({
showPending?: boolean showPending?: boolean
serviceId: number serviceId: number
sort: CommentSortOption sort: CommentSortOption
highlightedCommentId?: number | null
}) { }) {
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = [] const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
@@ -108,6 +112,8 @@ export function makeCommentsNestedQuery({
} }
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
const highlightedBranchIds = highlightedCommentId ? await findAllParentIds(highlightedCommentId, depth) : []
const baseQuery = { const baseQuery = {
...commentReplyQuery, ...commentReplyQuery,
orderBy: orderByClause, orderBy: orderByClause,
@@ -121,6 +127,9 @@ export function makeCommentsNestedQuery({
: ({ : ({
status: { in: ['APPROVED', 'VERIFIED'] }, status: { in: ['APPROVED', 'VERIFIED'] },
} as const satisfies Prisma.CommentWhereInput), } as const satisfies Prisma.CommentWhereInput),
...(highlightedBranchIds.length > 0
? [{ id: { in: highlightedBranchIds } } as const satisfies Prisma.CommentWhereInput]
: []),
], ],
parentId: null, parentId: null,
serviceId, serviceId,
@@ -161,6 +170,47 @@ export function makeRepliesQuery<T extends Prisma.CommentFindManyArgs>(
} }
} }
async function findAllParentIds(commentId: number, depth: number) {
const commentwithManyParents = await prisma.comment.findFirst({
where: { id: commentId },
select: makeParentQuerySelect(depth),
})
return extractParentIds(commentwithManyParents, [commentId])
}
type ParentQueryRecursive = {
parent: {
select: {
id: true
parent: false | { select: ParentQueryRecursive }
}
}
}
function makeParentQuerySelect(depth: number): ParentQueryRecursive {
return {
parent: {
select: {
id: true,
parent: depth <= 0 ? false : { select: makeParentQuerySelect(depth - 1) },
},
},
} as const satisfies Prisma.CommentSelect
}
function extractParentIds(
comment: Prisma.CommentGetPayload<{ select: ParentQueryRecursive }> | null,
acc: number[] = []
) {
if (!comment?.parent?.id) return acc
return extractParentIds(comment.parent as Prisma.CommentGetPayload<{ select: ParentQueryRecursive }>, [
...acc,
comment.parent.id,
])
}
export function makeCommentUrl({ export function makeCommentUrl({
serviceSlug, serviceSlug,
commentId, commentId,

View File

@@ -29,7 +29,7 @@ export async function stopImpersonating(context: Pick<APIContext, 'cookies' | 'l
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
await redisImpersonationSessions.delete(sessionId) await redisImpersonationSessions.delete(sessionId)
context.cookies.delete(IMPERSONATION_SESSION_COOKIE) context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
context.locals.user = context.locals.actualUser context.locals.user = context.locals.actualUser ?? context.locals.user
context.locals.actualUser = null context.locals.actualUser = null
} }

View File

@@ -1,4 +1,4 @@
import { addDays, format, isBefore, isToday, isYesterday } from 'date-fns' import { addDays, differenceInDays, format, isBefore, isToday, isYesterday } from 'date-fns'
import TimeAgo from 'javascript-time-ago' import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en' import en from 'javascript-time-ago/locale/en'
@@ -47,3 +47,10 @@ export function formatDateShort(
return transformCase(text, caseType) return transformCase(text, caseType)
} }
export function formatDaysAgo(approvedAt: Date) {
const days = differenceInDays(new Date(), approvedAt)
if (days === 0) return 'today'
if (days === 1) return 'yesterday'
return `${days.toLocaleString()} days ago`
}

View File

@@ -53,7 +53,7 @@ export async function removeUserSessionIdCookie(cookies: AstroCookies) {
cookies.delete(COOKIE_NAME, { path: '/' }) cookies.delete(COOKIE_NAME, { path: '/' })
} }
export async function logout(context: Pick<APIContext, 'cookies' | 'locals'>) { export async function logout(context: Pick<APIContext, 'cookies' | 'locals' | 'request' | 'url'>) {
await stopImpersonating(context) await stopImpersonating(context)
await removeUserSessionIdCookie(context.cookies) await removeUserSessionIdCookie(context.cookies)

View File

@@ -692,12 +692,12 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<Button <Button
as="button"
color="gray" color="gray"
variant="faded" variant="faded"
size="sm" size="sm"
label="Cancel" label="Cancel"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`} data-cancel-button
data-cancel-form-id={`edit-form-${index}`}
/> />
<Button <Button
as="button" as="button"
@@ -721,3 +721,22 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>
<script>
////////////////////////////////////////////////////////////
// Optional script for cancel buttons in attribute forms. //
// Hides the edit form when cancel button is clicked. //
////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLButtonElement>('[data-cancel-button]').forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault()
const formId = button.getAttribute('data-cancel-form-id')
if (!formId) throw new Error('Form ID not found')
const form = document.getElementById(formId)
if (!form) throw new Error('Form not found')
form.classList.add('hidden')
})
})
})
</script>

View File

@@ -163,7 +163,6 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
where: { userId: user.id }, where: { userId: user.id },
select: { select: {
endpoint: true, endpoint: true,
userAgent: true,
}, },
}), }),
[], [],