/opt/canhelp/apps/api/src/lib
NameSizeModeActions
activity.ts13240644editdlrm
apple-client-secret.ts40730644editdlrm
balance.ts35060644editdlrm
daily-limits.ts42820644editdlrm
email.ts527080644editdlrm
fomo.ts113660644editdlrm
google-oauth.ts105260644editdlrm
helper-capability.ts42070644editdlrm
messaging-rules.ts31230644editdlrm
nameUtils.ts7090644editdlrm
notif.ts189290644editdlrm
plan-history.ts10200644editdlrm
push.ts63860644editdlrm
referral.ts61100644editdlrm
telegram.ts62070644editdlrm
turnstile.ts7410644editdlrm
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], }) }