Files
famdone/frontend/src/lib/server/services/chat.ts
T
2026-09-11 12:06:51 +01:00

114 lines
3.2 KiB
TypeScript

import { sendMentionEmail } from '../email';
export type ChatActor = {
id: string;
type: 'admin' | 'member';
name: string;
color: string;
};
// Everyone in the family (children + parents) for the @mention picker.
export async function chatMe(pb: any, famId: string, actor: ChatActor) {
const users = await pb
.collection('users')
.getFullList({ filter: `famId = '${famId}'` })
.catch(() => []);
return {
famId,
actor,
members: users.map((m: any) => ({
id: m.id,
name: m.name || '',
color: m.color || '#6366f1',
role: m.role === 'parent' ? 'parent' : 'child'
}))
};
}
export async function send(
pb: any,
famId: string,
actor: ChatActor,
body: { content?: string; clientId?: string; siteUrl?: string }
) {
const content = (body.content || '').trim();
if (!content) throw new Error('content required');
const record = await pb.collection('messages').create({
famId,
authorType: actor.type,
authorId: actor.id,
authorName: actor.name,
authorColor: actor.color,
content,
createdAt: new Date().toISOString(),
clientId: body.clientId ? String(body.clientId).slice(0, 64) : ''
});
// Email any @mentioned parents (their in-app toast is client-side, via the
// same mention tokens). Fire-and-forget — never fail the message itself.
notifyMentionedParents(pb, famId, actor, record, body.siteUrl || '').catch(() => {});
return record;
}
// Scan the message for `@Parent Name` tokens (case-insensitive) and email every
// mentioned parent who isn't the sender, with a link back to the family site.
async function notifyMentionedParents(
pb: any,
famId: string,
actor: ChatActor,
message: any,
siteUrl: string
) {
const content = String(message.content || '');
const parents = await pb
.collection('users')
.getFullList({ filter: `famId = '${famId}' && role = 'parent'` })
.catch(() => []);
const mentioned = parents.filter((u: any) => {
if (u.id === actor.id || !u.email) return false;
const name = (u.name || '').trim().toLowerCase();
return !!name && content.toLowerCase().includes('@' + name);
});
if (!mentioned.length) return;
const fam = await pb
.collection('fams')
.getOne(famId)
.catch(() => null);
// Don't send emails for the demo family.
if (fam?.slug === 'showboaters') return;
const dashboardUrl = `${siteUrl}/${fam?.slug || famId}`;
for (const parent of mentioned) {
try {
await sendMentionEmail({
to: parent.email,
parentName: parent.name || 'there',
authorName: message.authorName || 'Family member',
message: content,
dashboardUrl
});
} catch (err) {
console.error(`[chat] Failed to send mention email to ${parent.email}:`, err);
}
}
}
export async function typing(pb: any, famId: string, actor: ChatActor, body: { typing?: boolean }) {
const existing = await pb.collection('chat_typing').getFullList({
filter: `famId = '${famId}' && actorId = '${actor.id}' && actorType = '${actor.type}'`
});
const row = {
famId,
actorId: actor.id,
actorType: actor.type,
authorName: actor.name,
authorColor: actor.color,
typing: !!body.typing
};
if (existing?.length) {
await pb.collection('chat_typing').update(existing[0].id, row);
} else {
await pb.collection('chat_typing').create(row);
}
return { ok: true };
}