Release 202506020353

This commit is contained in:
pluja
2025-06-02 03:53:03 +00:00
parent d065910ff3
commit 6a6908518d
32 changed files with 1507 additions and 230 deletions

View File

@@ -65,6 +65,14 @@ const adminLinks: AdminLink[] = [
base: 'text-pink-300',
},
},
{
icon: 'ri:notification-3-line',
title: 'Notifications',
href: '/admin/notifications',
classNames: {
base: 'text-indigo-300',
},
},
{
icon: 'ri:rocket-2-line',
title: 'Releases',

View File

@@ -0,0 +1,155 @@
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { groupBy, round, uniq } from 'lodash-es'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { cn } from '../../lib/cn'
import { prisma } from '../../lib/prisma'
// Check if user is admin
if (!Astro.locals.user?.admin) {
return Astro.redirect('/access-denied')
}
const testResult = Astro.getActionResult(actions.admin.notification.webPush.test)
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
const subscriptions = await Astro.locals.banners.try(
'Error while fetching subscriptions by user',
() =>
prisma.pushSubscription.findMany({
select: {
id: true,
user: {
select: {
id: true,
name: true,
},
},
},
}),
[] as []
)
const totalSubscriptions = subscriptions.length
const subscriptionsByUser = groupBy(subscriptions, 'user.id')
const totalUsers = Object.keys(subscriptionsByUser).length
const adminUsers = await prisma.user.findMany({
where: {
admin: true,
},
select: {
name: true,
},
})
const stats = [
{
icon: 'ri:notification-4-line',
iconClass: 'text-blue-400',
title: 'Total Subscriptions',
value: totalSubscriptions.toLocaleString(),
},
{
icon: 'ri:user-3-line',
iconClass: 'text-green-400',
title: 'Subscribed Users',
value: totalUsers.toLocaleString(),
},
{
icon: 'ri:smartphone-line',
iconClass: 'text-purple-400',
title: 'Avg Devices/User',
value: (totalUsers > 0 ? round(totalSubscriptions / totalUsers, 1) : 0).toLocaleString(),
},
] satisfies {
icon: string
iconClass: string
title: string
value: string
}[]
---
<MiniLayout
pageTitle="Push notifications"
description="Send test notifications"
layoutHeader={{
icon: 'ri:notification-3-line',
title: 'Push notifications',
subtitle: 'Send test notifications',
}}
>
<div class="mb-6 grid gap-4 sm:grid-cols-3">
{
stats.map((stat) => (
<div class="flex flex-col items-center gap-2 text-center">
<div class="flex items-end gap-1">
<span class="text-5xl leading-[0.8] font-bold text-white">{stat.value}</span>
<Icon name={stat.icon} class={cn('size-5 shrink-0', stat.iconClass)} />
</div>
<span class="text-day-200 flex grow flex-col justify-center text-sm leading-none font-medium text-balance">
{stat.title}
</span>
</div>
))
}
</div>
<h2 class="text-center text-lg font-semibold text-white">Send Test Notification</h2>
<form method="POST" action={actions.admin.notification.webPush.test} class="space-y-4">
<InputTextArea
label="Users"
name="userNames"
inputProps={{
placeholder: 'john-doe, jane-doe',
class: 'leading-tight min-h-24',
}}
value={uniq([Astro.locals.user, ...adminUsers].map((user) => user.name)).join('\n')}
description={[
'- Comma-separated list of user names.',
'- Minimum 1 user name.',
'- By default, all admin users are selected.',
].join('\n')}
error={testInputErrors.userNames}
/>
<InputText
label="Title"
name="title"
inputProps={{
value: 'Test Notification',
required: true,
}}
error={testInputErrors.title}
/>
<InputTextArea
label="Body"
name="body"
inputProps={{
value: 'This is a test push notification from KYCNot.me',
}}
error={testInputErrors.body}
/>
<InputText
label="Action URL"
name="url"
inputProps={{
placeholder: 'https://example.com/path',
}}
description="URL to open when the notification is clicked"
error={testInputErrors.url}
/>
<InputSubmitButton label="Send" icon="ri:send-plane-line" hideCancel color="danger" />
</form>
</MiniLayout>

View File

@@ -26,6 +26,7 @@ import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
import { formatContactMethod } from '../../../../constants/contactMethods'
import { currencies } from '../../../../constants/currencies'
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
import { kycLevels } from '../../../../constants/kycLevels'
import { serviceVisibilities } from '../../../../constants/serviceVisibility'
import { verificationStatuses } from '../../../../constants/verificationStatus'
@@ -415,6 +416,22 @@ const apiCalls = await Astro.locals.banners.try(
class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]"
/>
<InputCardGroup
name="kycLevelClarification"
label="KYC Level Clarification"
options={kycLevelClarifications.map((clarification) => ({
label: clarification.label,
value: clarification.value,
icon: clarification.icon,
description: clarification.description,
noTransitionPersist: true,
}))}
selectedValue={service.kycLevelClarification ?? 'NONE'}
iconSize="sm"
cardSize="sm"
error={serviceInputErrors.kycLevelClarification}
/>
<InputCardGroup
name="verificationStatus"
label="Verification Status"

View File

@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro'
export const ALL: APIRoute = (context) => {
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
return new Response(
JSON.stringify({
error: 'Endpoint not found',
}),
{
status: 404,
}
)
}

View File

@@ -0,0 +1,7 @@
import { actions } from 'astro:actions'
import { makeEndpointFromAction } from '../../../lib/endpoints'
import type { APIRoute } from 'astro'
export const POST: APIRoute = makeEndpointFromAction(actions.notification.webPush.subscribe)

View File

@@ -0,0 +1,7 @@
import { actions } from 'astro:actions'
import { makeEndpointFromAction } from '../../../lib/endpoints'
import type { APIRoute } from 'astro'
export const POST: APIRoute = makeEndpointFromAction(actions.notification.webPush.unsubscribe)

View File

@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import Button from '../components/Button.astro'
import PushNotificationBanner from '../components/PushNotificationBanner.astro'
import TimeFormatted from '../components/TimeFormatted.astro'
import Tooltip from '../components/Tooltip.astro'
import { getNotificationTypeInfo } from '../constants/notificationTypes'
@@ -28,131 +29,146 @@ const { data: params } = zodParseQueryParamsStoringErrors(
)
const skip = (params.page - 1) * PAGE_SIZE
const [dbNotifications, notificationPreferences, totalNotifications] = await Astro.locals.banners.tryMany([
[
'Error while fetching notifications',
() =>
prisma.notification.findMany({
where: {
userId: user.id,
},
orderBy: {
createdAt: 'desc',
},
skip,
take: PAGE_SIZE,
select: {
const [dbNotifications, notificationPreferences, totalNotifications, pushSubscriptions] =
await Astro.locals.banners.tryMany([
[
'Error while fetching notifications',
() =>
prisma.notification.findMany({
where: {
userId: user.id,
},
orderBy: {
createdAt: 'desc',
},
skip,
take: PAGE_SIZE,
select: {
id: true,
type: true,
createdAt: true,
read: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: {
select: {
id: true,
},
},
status: true,
content: true,
communityNote: true,
service: {
select: {
slug: true,
name: true,
},
},
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
},
},
aboutServiceSuggestionId: true,
aboutServiceSuggestion: {
select: {
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutEvent: {
select: {
title: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
},
}),
[],
],
[
'Error while fetching notification preferences',
() =>
getOrCreateNotificationPreferences(user.id, {
id: true,
type: true,
createdAt: true,
read: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: {
select: {
id: true,
},
},
status: true,
content: true,
communityNote: true,
service: {
select: {
slug: true,
name: true,
},
},
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
},
enableOnMyCommentStatusChange: true,
enableAutowatchMyComments: true,
enableNotifyPendingRepliesOnWatch: true,
karmaNotificationThreshold: true,
}),
null,
],
[
'Error while fetching total notifications',
() => prisma.notification.count({ where: { userId: user.id } }),
0,
],
[
'Error while fetching push subscriptions',
() =>
prisma.pushSubscription.findMany({
where: { userId: user.id },
select: {
endpoint: true,
userAgent: true,
},
aboutServiceSuggestionId: true,
aboutServiceSuggestion: {
select: {
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutEvent: {
select: {
title: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
},
}),
[],
],
[
'Error while fetching notification preferences',
() =>
getOrCreateNotificationPreferences(user.id, {
id: true,
enableOnMyCommentStatusChange: true,
enableAutowatchMyComments: true,
enableNotifyPendingRepliesOnWatch: true,
karmaNotificationThreshold: true,
}),
null,
],
[
'Error while fetching total notifications',
() => prisma.notification.count({ where: { userId: user.id } }),
0,
],
])
}),
[],
],
])
if (!notificationPreferences) console.error('No notification preferences found')
const totalPages = Math.ceil(totalNotifications / PAGE_SIZE)
@@ -199,6 +215,17 @@ const notifications = dbNotifications.map((notification) => ({
}}
>
<section class="mx-auto w-full">
{
notifications.length >= 5 && (
<PushNotificationBanner
class="mb-4"
dismissable
pushSubscriptions={pushSubscriptions}
hideIfEnabled
/>
)
}
<div class="mb-4 flex items-center justify-between">
<h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider">
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
@@ -306,57 +333,57 @@ const notifications = dbNotifications.map((notification) => ({
)
}
{
!!notificationPreferences && (
<div class="mt-8">
<h2 class="font-title mb-3 flex items-center border-b border-zinc-800 text-lg font-bold">
<Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
Notification Settings
</h2>
<h2 class="font-title mt-8 mb-3 flex items-center border-b border-zinc-800 text-lg font-bold">
<Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
Notification Settings
</h2>
<form
method="POST"
action={actions.notification.preferences.update}
class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
>
{notificationPreferenceFields.map((field) => (
<label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
<span class="flex items-center text-zinc-300">
<Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
{field.label}
</span>
<input
type="checkbox"
name={field.id}
checked={notificationPreferences[field.id]}
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
/>
</label>
))}
<PushNotificationBanner class="mb-3" pushSubscriptions={pushSubscriptions} />
<div class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
<span class="flex items-center text-zinc-300">
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" />
Notify me when my karma changes by at least
</span>
<div class="flex items-center gap-2">
<input
type="number"
name="karmaNotificationThreshold"
value={notificationPreferences.karmaNotificationThreshold}
min="1"
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none"
/>
<span class="text-zinc-400">points</span>
</div>
</div>
<form
method="POST"
action={actions.notification.preferences.update}
class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
>
{
notificationPreferenceFields.map((field) => (
<label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
<span class="flex items-center text-zinc-300">
<Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
{field.label}
</span>
<input
type="checkbox"
name={field.id}
checked={notificationPreferences?.[field.id]}
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
/>
</label>
))
}
<div class="mt-4 flex justify-end">
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
</div>
</form>
<div
class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800"
>
<span class="flex items-center text-zinc-300">
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" />
Notify me when my karma changes by at least
</span>
<div class="flex items-center gap-2">
<input
type="number"
name="karmaNotificationThreshold"
value={notificationPreferences?.karmaNotificationThreshold}
min="1"
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none"
/>
<span class="text-zinc-400">points</span>
</div>
)
}
</div>
<div class="mt-4 flex justify-end">
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
</div>
</form>
</section>
</BaseLayout>

View File

@@ -20,6 +20,7 @@ import InputTextArea from '../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { currencies } from '../../constants/currencies'
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
import { kycLevels } from '../../constants/kycLevels'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { prisma } from '../../lib/prisma'
@@ -242,6 +243,21 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
error={inputErrors.kycLevel}
/>
<InputCardGroup
name="kycLevelClarification"
label="KYC Level Clarification"
options={kycLevelClarifications.map((clarification) => ({
label: clarification.label,
value: clarification.value,
icon: clarification.icon,
description: clarification.description,
}))}
selectedValue="NONE"
iconSize="sm"
cardSize="sm"
error={inputErrors.kycLevelClarification}
/>
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
<InputCheckboxGroup
name="categories"

View File

@@ -1,5 +1,5 @@
---
import { VerificationStepStatus, EventType } from '@prisma/client'
import { VerificationStepStatus, EventType, KycLevelClarification } from '@prisma/client'
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema'
@@ -32,6 +32,7 @@ import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { formatContactMethod } from '../../constants/contactMethods'
import { currencies, getCurrencyInfo } from '../../constants/currencies'
import { getEventTypeInfo } from '../../constants/eventTypes'
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
import { getServiceVisibilityInfo } from '../../constants/serviceVisibility'
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
@@ -78,6 +79,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
name: true,
description: true,
kycLevel: true,
kycLevelClarification: true,
overallScore: true,
privacyScore: true,
trustScore: true,
@@ -308,6 +310,9 @@ const hiddenLinks = [
]
const kycLevelInfo = getKycLevelInfo(`${service.kycLevel}`)
const kycLevelClarificationInfo = getKycLevelClarificationInfo(service.kycLevelClarification)
const userSentiment = service.userSentiment
? { ...service.userSentiment, info: getUserSentimentInfo(service.userSentiment.sentiment) }
: null
@@ -896,8 +901,12 @@ const activeAlertOrWarningEvents = service.events.filter(
'@type': 'Review',
itemReviewed: { '@id': itemReviewedId },
reviewAspect: 'KYC Level',
name: kycLevelInfo.name,
reviewBody: kycLevelInfo.description,
name:
kycLevelInfo.name +
(kycLevelClarificationInfo.value !== 'NONE' ? ` (${kycLevelClarificationInfo.label})` : ''),
reviewBody:
kycLevelInfo.description +
(kycLevelClarificationInfo.value !== 'NONE' ? ` ${kycLevelClarificationInfo.description}` : ''),
reviewRating: {
'@type': 'Rating',
ratingValue: kycLevelInfo.value,
@@ -918,9 +927,22 @@ const activeAlertOrWarningEvents = service.events.filter(
</div>
<dl class="flex-grow-5 basis-0">
<dt class="text-base font-bold text-pretty">{kycLevelInfo.name}</dt>
<dt class="text-base font-bold text-pretty">
{kycLevelInfo.name}
{
kycLevelClarificationInfo.value !== 'NONE' && (
<>
<span class="text-day-400 mx-1">•</span>
<span class="text-blue-500">{kycLevelClarificationInfo.label}</span>
</>
)
}
</dt>
<dd class="text-day-700 mt-1 font-sans text-sm text-pretty">
{kycLevelInfo.description}
<span class="font-medium text-blue-600">
{kycLevelClarificationInfo.value !== 'NONE' && ` ${kycLevelClarificationInfo.description}`}
</span>
</dd>
</dl>
</div>