/opt/canhelp/apps/api/src/lib
Edit: /opt/canhelp/apps/api/src/lib/notif.ts (18929B)
import { eq } from 'drizzle-orm'
import { db } from '../db.js'
import { users, notifications } from '@canhelp/db'
import { logActivity } from './activity.js'
import { sendPushToUser } from './push.js'
type Locale = 'el' | 'en' | 'ru'
const t = {
new_offer: {
title: { el: 'Νέα προσφορά', en: 'New offer', ru: 'Новое предложение' },
body: (title: string) => ({
el: `Νέα προσφορά για "${title}"`,
en: `New offer for "${title}"`,
ru: `Новое предложение на «${title}»`,
}),
},
offer_accepted: {
title: { el: 'Η προσφορά σας έγινε δεκτή', en: 'Your offer was accepted', ru: 'Ваше предложение принято' },
body: (title: string) => ({
el: `Η προσφορά σας για "${title}" έγινε δεκτή`,
en: `Your offer for "${title}" was accepted`,
ru: `Ваше предложение на «${title}» принято`,
}),
},
offer_declined: {
title: { el: 'Η προσφορά σας απορρίφθηκε', en: 'Your offer was declined', ru: 'Ваше предложение отклонено' },
body: (title: string) => ({
el: `Η προσφορά σας για "${title}" απορρίφθηκε`,
en: `Your offer for "${title}" was declined`,
ru: `Ваше предложение на «${title}» отклонено`,
}),
},
offer_other_accepted: {
title: { el: 'Επιλέχθηκε άλλος ειδικός', en: 'Another specialist was chosen', ru: 'Выбран другой исполнитель' },
body: (title: string) => ({
el: `Για "${title}" επιλέχθηκε άλλος ειδικός`,
en: `Another specialist was chosen for "${title}"`,
ru: `Для «${title}» выбран другой исполнитель`,
}),
},
task_updated: {
title: { el: 'Η εργασία ενημερώθηκε', en: 'Task updated', ru: 'Задание обновлено' },
body: (title: string) => ({
el: `Η εργασία "${title}" ενημερώθηκε από τον πελάτη`,
en: `The task "${title}" was updated by the customer`,
ru: `Заказ «${title}» был обновлён заказчиком`,
}),
},
task_deadline_reminder: {
title: { el: 'Υπενθύμιση προθεσμίας', en: 'Deadline reminder', ru: 'Напоминание о дедлайне' },
body: (title: string) => ({
el: `Η εργασία "${title}" λήγει σε 24 ώρες. Αλλάξτε την ημερομηνία ή θα αποσυρθεί αυτόματα.`,
en: `Task "${title}" expires in 24 hours. Update the deadline or it will be unpublished automatically.`,
ru: `Задание «${title}» истекает через 24 часа. Обновите дедлайн или задание будет снято с публикации.`,
}),
},
task_archived: {
title: { el: 'Η εργασία αποσύρθηκε', en: 'Task unpublished', ru: 'Задание снято с публикации' },
body: (title: string) => ({
el: `Η εργασία "${title}" έληξε και μεταφέρθηκε αυτόματα σε πρόχειρο. Αλλάξτε την ημερομηνία για να την επαναδημοσιεύσετε.`,
en: `Task "${title}" expired and was automatically moved to draft. Update the deadline to republish it.`,
ru: `Задание «${title}» истекло и автоматически переведено в черновик. Обновите дедлайн, чтобы опубликовать его снова.`,
}),
},
new_message: {
title: { el: 'Νέο μήνυμα', en: 'New message', ru: 'Новое сообщение' },
},
new_review: {
title: { el: 'Νέα αξιολόγηση', en: 'New review', ru: 'Новый отзыв' },
body: (rating: number) => ({
el: `Λάβατε αξιολόγηση ${rating}★`,
en: `You received a ${rating}★ review`,
ru: `Вы получили оценку ${rating}★`,
}),
},
support_ticket_reply: {
title: { el: 'Απάντηση από υποστήριξη', en: 'Support replied', ru: 'Ответ техподдержки' },
body: (subject: string) => ({
el: `Νέα απάντηση για το αίτημα: "${subject}"`,
en: `New reply for your ticket: "${subject}"`,
ru: `Новый ответ по обращению: «${subject}»`,
}),
},
specialist_card_submitted: {
title: { el: 'Νέα κάρτα για έλεγχο', en: 'New card for review', ru: 'Новая карточка на проверку' },
body: (title: string) => ({
el: `Η κάρτα "${title}" στάλθηκε για έλεγχο`,
en: `The card "${title}" was sent for review`,
ru: `Карточка «${title}» отправлена на проверку`,
}),
},
specialist_card_approved: {
title: { el: 'Η κάρτα εγκρίθηκε', en: 'Card approved', ru: 'Карточка одобрена' },
body: (title: string) => ({
el: `Η κάρτα "${title}" εγκρίθηκε και δημοσιεύτηκε`,
en: `The card "${title}" was approved and published`,
ru: `Карточка «${title}» одобрена и опубликована`,
}),
},
specialist_card_rejected: {
title: { el: 'Η κάρτα απορρίφθηκε', en: 'Card rejected', ru: 'Карточка отклонена' },
body: (title: string) => ({
el: `Η κάρτα "${title}" δεν εγκρίθηκε. Μπορείτε να τη διορθώσετε και να τη στείλετε ξανά.`,
en: `The card "${title}" was not approved. You can fix it and submit again.`,
ru: `Карточка «${title}» не была одобрена. Вы можете исправить её и отправить снова.`,
}),
},
} as const
async function getLocale(recipientId: string): Promise
{
const [row] = await db.select({ locale: users.locale }).from(users).where(eq(users.id, recipientId))
const loc = row?.locale as string | undefined
return (loc === 'en' || loc === 'ru' ? loc : 'el') as Locale
}
async function getRecipientPrefs(recipientId: string): Promise<{ locale: Locale; notifMessages: boolean }> {
const [row] = await db
.select({ locale: users.locale, notifMessages: users.notifMessages })
.from(users)
.where(eq(users.id, recipientId))
const loc = row?.locale as string | undefined
return {
locale: (loc === 'en' || loc === 'ru' ? loc : 'el') as Locale,
notifMessages: row?.notifMessages ?? true,
}
}
async function createNotification(values: typeof notifications.$inferInsert) {
try {
const [row] = await db.insert(notifications).values(values).returning()
// Fire-and-forget push delivery. In-app notification stays the source of truth.
sendPushToUser(values.recipientId, {
title: values.title,
body: values.body,
data: {
type: values.type,
referenceId: values.referenceId ?? '',
},
}).catch((err) => console.error('[push] send failed', err))
await logActivity({
userId: values.recipientId,
event: 'notification.created',
details: {
notificationId: row?.id ?? null,
type: values.type,
referenceId: values.referenceId ?? null,
title: values.title,
},
})
return row ?? null
} catch (err) {
await logActivity({
userId: values.recipientId,
event: 'notification.failed',
details: {
type: values.type,
referenceId: values.referenceId ?? null,
title: values.title,
error: err instanceof Error ? err.message : String(err),
},
})
throw err
}
}
/** Creates a localized notification for new offer */
export async function notifyNewOffer(recipientId: string, taskTitle: string, referenceId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'new_offer',
title: t.new_offer.title[loc],
body: t.new_offer.body(taskTitle)[loc],
referenceId,
})
}
/** Creates a localized notification for offer accepted */
export async function notifyOfferAccepted(recipientId: string, taskTitle: string, referenceId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'offer_accepted',
title: t.offer_accepted.title[loc],
body: t.offer_accepted.body(taskTitle)[loc],
referenceId,
})
}
/** Creates a localized notification for offer declined */
export async function notifyOfferDeclined(recipientId: string, taskTitle: string, referenceId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'offer_declined',
title: t.offer_declined.title[loc],
body: t.offer_declined.body(taskTitle)[loc],
referenceId,
})
}
/** Creates a localized notification when another specialist was chosen */
export async function notifyOfferOtherAccepted(recipientId: string, taskTitle: string, referenceId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'offer_other_accepted',
title: t.offer_other_accepted.title[loc],
body: t.offer_other_accepted.body(taskTitle)[loc],
referenceId,
})
}
/** Creates a localized notification for new message, returns the inserted row (or null if disabled) */
export async function notifyNewMessage(
recipientId: string,
body: string,
referenceId: string,
dbInstance: typeof db = db,
) {
const { locale: loc, notifMessages } = await getRecipientPrefs(recipientId)
if (!notifMessages) return null
const [row] = await dbInstance
.insert(notifications)
.values({
recipientId,
type: 'new_message',
title: t.new_message.title[loc],
body: body.slice(0, 100),
referenceId,
})
.returning()
// Keep push behavior consistent with other in-app notifications.
sendPushToUser(recipientId, {
title: t.new_message.title[loc],
body: body.slice(0, 100),
data: {
type: 'new_message',
referenceId,
},
}).catch((err) => console.error('[push] send failed', err))
await logActivity({
userId: recipientId,
event: 'notification.created',
details: { notificationId: row?.id ?? null, type: 'new_message', referenceId },
})
return row
}
/** Creates a localized notification for new review */
export async function notifyNewReview(recipientId: string, rating: number, referenceId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'new_review',
title: t.new_review.title[loc],
body: t.new_review.body(rating)[loc],
referenceId,
})
}
const taskInvite = {
title: { el: 'Πρόσκληση σε εργασία', en: 'Job invitation', ru: 'Приглашение на задание' },
body: (taskTitle: string) => ({
el: `Σας προσκαλούν για "${taskTitle}"`,
en: `You've been invited for "${taskTitle}"`,
ru: `Вас приглашают на «${taskTitle}»`,
}),
}
/** Creates a localized task-invite notification */
export async function notifyTaskInvite(recipientId: string, taskTitle: string, referenceId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'task_invite' as any,
title: taskInvite.title[loc],
body: taskInvite.body(taskTitle)[loc],
referenceId,
})
}
/** Creates a localized notification when a task is updated by the customer */
export async function notifyTaskUpdated(recipientId: string, taskTitle: string, referenceId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'task_updated' as any,
title: t.task_updated.title[loc],
body: t.task_updated.body(taskTitle)[loc],
referenceId,
})
}
/** Creates a localized deadline reminder notification for a task owner */
export async function notifyTaskDeadlineReminder(recipientId: string, taskTitle: string, referenceId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'task_deadline_reminder' as any,
title: t.task_deadline_reminder.title[loc],
body: t.task_deadline_reminder.body(taskTitle)[loc],
referenceId,
})
}
/** Creates a localized notification when a task is auto-moved to draft after deadline */
export async function notifyTaskArchived(recipientId: string, taskTitle: string, referenceId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'task_archived' as any,
title: t.task_archived.title[loc],
body: t.task_archived.body(taskTitle)[loc],
referenceId,
})
}
const referralRewardStrings = {
title: { el: 'Λάβατε ανταμοιβή παραπομπής', en: 'You received a referral reward', ru: 'Вы получили реферальную награду' },
body: (days: number, planName: string) => ({
el: `${days} ημέρες πρόσβαση στο πλάνο «${planName}» προστέθηκαν στον λογαριασμό σας!`,
en: `${days} days of «${planName}» plan access have been added to your account!`,
ru: `${days} дней доступа к плану «${planName}» добавлены на ваш аккаунт!`,
}),
}
/** Creates a localized notification when a referral reward is applied to the referrer */
export async function notifyReferralReward(recipientId: string, days: number, planName: string, rewardId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'referral_reward',
title: referralRewardStrings.title[loc],
body: referralRewardStrings.body(days, planName)[loc],
referenceId: rewardId,
})
}
/** Creates a localized notification when support replies to a user ticket */
export async function notifySupportTicketReply(recipientId: string, subject: string, ticketId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'support_ticket_reply' as any,
title: t.support_ticket_reply.title[loc],
body: t.support_ticket_reply.body(subject)[loc],
referenceId: ticketId,
})
}
export async function notifySpecialistCardSubmitted(recipientId: string, cardTitle: string, cardId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'specialist_card_submitted' as any,
title: t.specialist_card_submitted.title[loc],
body: t.specialist_card_submitted.body(cardTitle)[loc],
referenceId: cardId,
})
}
export async function notifySpecialistCardApproved(recipientId: string, cardTitle: string, cardId: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'specialist_card_approved' as any,
title: t.specialist_card_approved.title[loc],
body: t.specialist_card_approved.body(cardTitle)[loc],
referenceId: cardId,
})
}
export async function notifySpecialistCardRejected(recipientId: string, cardTitle: string, cardId: string, adminComment?: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'specialist_card_rejected' as any,
title: t.specialist_card_rejected.title[loc],
body: `${t.specialist_card_rejected.body(cardTitle)[loc]}${adminComment ? `\n\n${adminComment}` : ''}`,
referenceId: cardId,
})
}
const planStrings = {
renewal_reminder: {
title: { el: 'Υπενθύμιση ανανέωσης πλάνου', en: 'Plan renewal reminder', ru: 'Напоминание о продлении тарифа' },
body: (planName: string, amount: string, expiresAt: string) => ({
el: `Το πλάνο «${planName}» λήγει στις ${expiresAt}. Θα χρεωθεί ${amount}€ από το υπόλοιπό σας.`,
en: `Your «${planName}» plan expires on ${expiresAt}. ${amount}€ will be charged from your balance.`,
ru: `Тариф «${planName}» истекает ${expiresAt}. С баланса будет списано ${amount}€.`,
}),
},
insufficient_funds: {
title: { el: 'Ανεπαρκές υπόλοιπο', en: 'Insufficient balance', ru: 'Недостаточно средств' },
body: (planName: string, required: string, balance: string) => ({
el: `Το πλάνο «${planName}» δεν μπορεί να ανανεωθεί. Χρειάζεται ${required}€, υπόλοιπο: ${balance}€. Πλάνο θα υποβαθμιστεί αυτόματα.`,
en: `«${planName}» plan cannot be renewed. Required: ${required}€, your balance: ${balance}€. Plan will downgrade automatically.`,
ru: `Тариф «${planName}» не может быть продлён. Нужно ${required}€, баланс: ${balance}€. Тариф будет автоматически понижен.`,
}),
},
charged: {
title: { el: 'Το πλάνο ανανεώθηκε', en: 'Plan renewed', ru: 'Тариф продлён' },
body: (planName: string, amount: string) => ({
el: `Το πλάνο «${planName}» ανανεώθηκε επιτυχώς. Χρεώθηκε ${amount}€ από το υπόλοιπό σας.`,
en: `«${planName}» plan renewed successfully. ${amount}€ was charged from your balance.`,
ru: `Тариф «${planName}» успешно продлён. С баланса списано ${amount}€.`,
}),
},
}
export async function notifyPlanRenewalReminder(recipientId: string, planName: string, amount: string, expiresAt: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'plan_renewal_reminder' as any,
title: planStrings.renewal_reminder.title[loc],
body: planStrings.renewal_reminder.body(planName, amount, expiresAt)[loc],
})
}
export async function notifyPlanInsufficientFunds(recipientId: string, planName: string, required: string, balance: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'plan_insufficient_funds' as any,
title: planStrings.insufficient_funds.title[loc],
body: planStrings.insufficient_funds.body(planName, required, balance)[loc],
})
}
export async function notifyPlanCharged(recipientId: string, planName: string, amount: string) {
const loc = await getLocale(recipientId)
await createNotification({
recipientId,
type: 'plan_charged' as any,
title: planStrings.charged.title[loc],
body: planStrings.charged.body(planName, amount)[loc],
})
}