feat: §6 Gruppo B residuo — pannello macros, esclusioni granulari, achievement
- Plan: pannello macros laterale "oggi" con consumati/pianificati vs target (kcal/P/F/C). Sticky su lg, in-flow su mobile. Profile/derived ora include kcalTarget e i macro target (BMR aggiustato per condizioni e diet history). - Preferenze: nuova sezione "Ingredienti specifici da evitare" con ricerca debounced sul catalogo (cap 50). Schema Preferences.bannedIngredientIds + matchmaking che scarta ricette contenenti ingredient bandito (test unit). - Achievement: tabella Achievement (userId+key unique), 4 traguardi iniziali (first_weigh_in, first_plan, first_fast_complete, ten_meals_consumed). Service idempotente eval+persist agganciato a 4 trigger (weight POST, plan POST, fast PATCH→COMPLETED, slot consumed). Push notification best-effort all'unlock. Pannello badge nel profilo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3b895191f5
commit
d17c5ef869
29 files changed
+1048
-86
No files matched your search
@@ -6,6 +6,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
import { env } from './config/env.js';
|
||||
import { Sentry } from './lib/sentry.js';
|
||||
import { achievementsRoutes } from './modules/achievements/achievements.routes.js';
|
||||
import { dbRoutes } from './modules/db/db.routes.js';
|
||||
import { healthRoutes } from './modules/health/health.routes.js';
|
||||
import { gdprRoutes } from './modules/me/gdpr.routes.js';
|
||||
@@ -66,6 +67,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(planExportRoutes);
|
||||
await app.register(shoppingRoutes);
|
||||
await app.register(notificationsRoutes);
|
||||
await app.register(achievementsRoutes);
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
app.setErrorHandler((err, request, reply) => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// PRD §6 — route achievement: lista degli sbloccati per l'utente, con i
|
||||
// "locked" inferiti lato client dal catalogo statico.
|
||||
|
||||
import { ACHIEVEMENT_KEYS, type AchievementKey } from '@ketopath/shared';
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
|
||||
export const achievementsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get('/me/achievements', { preHandler: requireAuth() }, async (request) => {
|
||||
const userId = request.user!.id;
|
||||
const rows = await fastify.prisma.achievement.findMany({
|
||||
where: { userId },
|
||||
orderBy: { unlockedAt: 'desc' },
|
||||
select: { key: true, unlockedAt: true },
|
||||
});
|
||||
// Filtra eventuali key legacy non più nel catalogo: niente errori UI.
|
||||
const validKeys = new Set<string>(ACHIEVEMENT_KEYS);
|
||||
const unlocked = rows
|
||||
.filter((r) => validKeys.has(r.key))
|
||||
.map((r) => ({ key: r.key as AchievementKey, unlockedAt: r.unlockedAt.toISOString() }));
|
||||
return { unlocked };
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
// PRD §6 — service achievement: orchestra l'eval e la persistenza.
|
||||
// Idempotente: rigirare la funzione senza nuovi eventi non duplica nulla
|
||||
// e non emette notifiche. Pensato per essere chiamato dopo i 4 trigger:
|
||||
// weight save, plan create, fast complete, slot consume.
|
||||
|
||||
import type { ExtendedPrismaClient } from '@ketopath/db';
|
||||
import { evaluateAchievements, type AchievementKey } from '@ketopath/shared';
|
||||
|
||||
import { pushIsEnabled, sendToDevice } from '../notifications/sender.js';
|
||||
|
||||
interface UnlockOutput {
|
||||
newlyUnlocked: AchievementKey[];
|
||||
}
|
||||
|
||||
export async function evaluateAndPersist(
|
||||
prisma: ExtendedPrismaClient,
|
||||
userId: string,
|
||||
): Promise<UnlockOutput> {
|
||||
// Misuriamo le metriche correnti in parallelo. Niente filtri sulla data:
|
||||
// è uno snapshot. La unique constraint su (userId, key) ci protegge dai
|
||||
// doppioni quando l'utente innesca trigger ripetuti.
|
||||
const [weightEntryCount, planCount, completedFastCount, consumedMealCount] = await Promise.all([
|
||||
prisma.weightEntry.count({ where: { userId } }),
|
||||
prisma.mealPlan.count({ where: { userId } }),
|
||||
prisma.fastEvent.count({ where: { userId, status: 'COMPLETED' } }),
|
||||
prisma.mealSlot.count({ where: { plan: { userId }, consumed: true } }),
|
||||
]);
|
||||
|
||||
const should = evaluateAchievements({
|
||||
weightEntryCount,
|
||||
planCount,
|
||||
completedFastCount,
|
||||
consumedMealCount,
|
||||
});
|
||||
if (should.length === 0) return { newlyUnlocked: [] };
|
||||
|
||||
const existing = await prisma.achievement.findMany({
|
||||
where: { userId, key: { in: should } },
|
||||
select: { key: true },
|
||||
});
|
||||
const have = new Set(existing.map((a) => a.key));
|
||||
const newlyUnlocked = should.filter((k) => !have.has(k));
|
||||
if (newlyUnlocked.length === 0) return { newlyUnlocked: [] };
|
||||
|
||||
// createMany con skipDuplicates: race-safe contro chiamate concorrenti.
|
||||
await prisma.achievement.createMany({
|
||||
data: newlyUnlocked.map((key) => ({ userId, key })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
return { newlyUnlocked };
|
||||
}
|
||||
|
||||
const ACHIEVEMENT_PUSH_COPY: Record<AchievementKey, { title: string; body: string }> = {
|
||||
first_weigh_in: {
|
||||
title: 'Achievement sbloccato: Prima pesata',
|
||||
body: 'Hai messo il primo punto sulla mappa. Ben fatto.',
|
||||
},
|
||||
first_plan: {
|
||||
title: 'Achievement sbloccato: Primo piano',
|
||||
body: 'Hai il tuo piano della settimana. Si parte.',
|
||||
},
|
||||
first_fast_complete: {
|
||||
title: 'Achievement sbloccato: Primo digiuno',
|
||||
body: 'Sessione completata. Costanza è la chiave.',
|
||||
},
|
||||
ten_meals_consumed: {
|
||||
title: 'Achievement sbloccato: 10 pasti',
|
||||
body: 'Dieci pasti spuntati. La routine sta diventando un ritmo.',
|
||||
},
|
||||
};
|
||||
|
||||
// Best-effort: non blocchiamo la response principale se push fallisce.
|
||||
export async function notifyUnlocked(
|
||||
prisma: ExtendedPrismaClient,
|
||||
userId: string,
|
||||
keys: AchievementKey[],
|
||||
): Promise<void> {
|
||||
if (keys.length === 0 || !pushIsEnabled()) return;
|
||||
const tokens = await prisma.deviceToken.findMany({ where: { userId } });
|
||||
if (tokens.length === 0) return;
|
||||
for (const key of keys) {
|
||||
const copy = ACHIEVEMENT_PUSH_COPY[key];
|
||||
for (const token of tokens) {
|
||||
const r = await sendToDevice(token, {
|
||||
title: copy.title,
|
||||
body: copy.body,
|
||||
url: '/profile',
|
||||
});
|
||||
if (r.expired) {
|
||||
await prisma.deviceToken.delete({ where: { id: token.id } }).catch(() => {
|
||||
/* race */
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
||||
|
||||
const MEALS = ['COLAZIONE', 'PRANZO', 'SPUNTINO', 'CENA'] as const;
|
||||
|
||||
@@ -93,7 +94,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
prepMinutes: true,
|
||||
ingredients: {
|
||||
select: {
|
||||
ingredient: { select: { exclusionGroups: true, phase2Week: true } },
|
||||
ingredient: { select: { id: true, exclusionGroups: true, phase2Week: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -117,8 +118,10 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
const candidates: RecipeCandidate[] = allowedRecipes.map((r) => {
|
||||
const tags = new Set<string>();
|
||||
const ingredientIds: string[] = [];
|
||||
for (const ri of r.ingredients) {
|
||||
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
|
||||
ingredientIds.push(ri.ingredient.id);
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -129,6 +132,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fatG: r.fatG,
|
||||
netCarbG: r.netCarbG,
|
||||
exclusionTags: Array.from(tags),
|
||||
ingredientIds,
|
||||
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
|
||||
prepMinutes: r.prepMinutes,
|
||||
};
|
||||
@@ -186,6 +190,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
phase: phaseInt,
|
||||
});
|
||||
const exclusions = profile.user.preferences?.exclusions ?? [];
|
||||
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
|
||||
const fastingProtocol =
|
||||
(profile.user.preferences?.fastingProtocol as FastingProtocolKey | null | undefined) ?? null;
|
||||
const cookingTime =
|
||||
@@ -239,6 +244,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
meal,
|
||||
phase: phaseInt,
|
||||
excludedTags: exclusions,
|
||||
bannedIngredientIds,
|
||||
recentlyConsumedIds,
|
||||
dailyTarget,
|
||||
mealShare: effectiveShare,
|
||||
@@ -259,6 +265,13 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
}
|
||||
}
|
||||
|
||||
const ach = await evaluateAndPersist(fastify.prisma, userId);
|
||||
if (ach.newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
plan: {
|
||||
id: plan.id,
|
||||
@@ -266,6 +279,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
dailyTarget: baseDailyTarget,
|
||||
fastingProtocol,
|
||||
},
|
||||
newlyUnlocked: ach.newlyUnlocked,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -366,7 +380,18 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
}
|
||||
}
|
||||
|
||||
return { slot: updated, autoStartedFast };
|
||||
let newlyUnlocked: string[] = [];
|
||||
if (next) {
|
||||
const ach = await evaluateAndPersist(fastify.prisma, userId);
|
||||
newlyUnlocked = ach.newlyUnlocked;
|
||||
if (newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { slot: updated, autoStartedFast, newlyUnlocked };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -450,13 +475,19 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
netCarbG: true,
|
||||
phases: true,
|
||||
prepMinutes: true,
|
||||
ingredients: { select: { ingredient: { select: { exclusionGroups: true } } } },
|
||||
ingredients: {
|
||||
select: { ingredient: { select: { id: true, exclusionGroups: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const candidates: RecipeCandidate[] = recipes.map((r) => {
|
||||
const tags = new Set<string>();
|
||||
for (const ri of r.ingredients) for (const g of ri.ingredient.exclusionGroups) tags.add(g);
|
||||
const ingredientIds: string[] = [];
|
||||
for (const ri of r.ingredients) {
|
||||
for (const g of ri.ingredient.exclusionGroups) tags.add(g);
|
||||
ingredientIds.push(ri.ingredient.id);
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -466,6 +497,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fatG: r.fatG,
|
||||
netCarbG: r.netCarbG,
|
||||
exclusionTags: Array.from(tags),
|
||||
ingredientIds,
|
||||
phases: r.phases.filter((p): p is 1 | 2 | 3 => p === 1 || p === 2 || p === 3),
|
||||
prepMinutes: r.prepMinutes,
|
||||
};
|
||||
@@ -536,6 +568,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
);
|
||||
|
||||
const exclusions = profile.user.preferences?.exclusions ?? [];
|
||||
const bannedIngredientIds = profile.user.preferences?.bannedIngredientIds ?? [];
|
||||
const cookingTime =
|
||||
(profile.user.preferences?.cookingTime as 'LOW' | 'MEDIUM' | 'HIGH' | null | undefined) ??
|
||||
null;
|
||||
@@ -553,6 +586,7 @@ export const planRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
meal: slot.meal,
|
||||
phase: phaseInt,
|
||||
excludedTags: exclusions,
|
||||
bannedIngredientIds,
|
||||
recentlyConsumedIds,
|
||||
consumedSoFar,
|
||||
dailyTarget,
|
||||
|
||||
@@ -15,6 +15,7 @@ function serialize(prefs: {
|
||||
trainingType: string | null;
|
||||
sessionMinutes: number | null;
|
||||
mealsPerDay: number | null;
|
||||
bannedIngredientIds: string[];
|
||||
}) {
|
||||
return {
|
||||
exclusions: prefs.exclusions,
|
||||
@@ -25,6 +26,7 @@ function serialize(prefs: {
|
||||
trainingType: prefs.trainingType,
|
||||
sessionMinutes: prefs.sessionMinutes,
|
||||
mealsPerDay: prefs.mealsPerDay,
|
||||
bannedIngredientIds: prefs.bannedIngredientIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +39,7 @@ const EMPTY_PREFS = {
|
||||
trainingType: null,
|
||||
sessionMinutes: null,
|
||||
mealsPerDay: null,
|
||||
bannedIngredientIds: [],
|
||||
};
|
||||
|
||||
export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
@@ -64,6 +67,7 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
trainingType: data.trainingType ?? null,
|
||||
sessionMinutes: data.sessionMinutes ?? null,
|
||||
mealsPerDay: data.mealsPerDay ?? null,
|
||||
bannedIngredientIds: data.bannedIngredientIds ?? [],
|
||||
};
|
||||
const update: Record<string, unknown> = {};
|
||||
if (data.exclusions !== undefined) update.exclusions = data.exclusions;
|
||||
@@ -74,6 +78,8 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
if (data.trainingType !== undefined) update.trainingType = data.trainingType;
|
||||
if (data.sessionMinutes !== undefined) update.sessionMinutes = data.sessionMinutes;
|
||||
if (data.mealsPerDay !== undefined) update.mealsPerDay = data.mealsPerDay;
|
||||
if (data.bannedIngredientIds !== undefined)
|
||||
update.bannedIngredientIds = data.bannedIngredientIds;
|
||||
|
||||
const prefs = await fastify.prisma.preferences.upsert({
|
||||
where: { userId },
|
||||
@@ -83,4 +89,33 @@ export const preferencesRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
return { preferences: serialize(prefs) };
|
||||
});
|
||||
|
||||
// PRD §6 — catalogo ingredienti per la UI di esclusioni granulari.
|
||||
// Ricerca case-insensitive per nome, oppure filtro per ids (per resolvere
|
||||
// i bannati attualmente salvati). Max 50 risultati.
|
||||
fastify.get('/me/ingredients', { preHandler: requireAuth() }, async (request) => {
|
||||
const query = request.query as { q?: string; ids?: string };
|
||||
const q = query.q?.trim() ?? '';
|
||||
const idsRaw = query.ids?.trim() ?? '';
|
||||
const where =
|
||||
idsRaw.length > 0
|
||||
? {
|
||||
id: {
|
||||
in: idsRaw
|
||||
.split(',')
|
||||
.filter((s) => s.length > 0)
|
||||
.slice(0, 50),
|
||||
},
|
||||
}
|
||||
: q.length > 0
|
||||
? { name: { contains: q, mode: 'insensitive' as const } }
|
||||
: {};
|
||||
const ingredients = await fastify.prisma.ingredient.findMany({
|
||||
where,
|
||||
select: { id: true, name: true, category: true, exclusionGroups: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
return { ingredients };
|
||||
});
|
||||
};
|
||||
@@ -1,11 +1,16 @@
|
||||
import {
|
||||
ACTIVITY_MULTIPLIERS,
|
||||
bmrAdjustForDietHistory,
|
||||
bmrAdjustmentForConditions,
|
||||
calculateBmr,
|
||||
calculateBmrKatchMcArdle,
|
||||
calculateTdee,
|
||||
computeDailyKcalTarget,
|
||||
estimateBodyFatPercentageUSNavy,
|
||||
macrosForPhase,
|
||||
profileInputSchema,
|
||||
type ActivityLevel,
|
||||
type DietHistory,
|
||||
type Gender,
|
||||
} from '@ketopath/shared';
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
@@ -36,7 +41,7 @@ function serialize(
|
||||
if (!profile) return null;
|
||||
const weightCurrentKg = Number(profile.weightCurrentKg);
|
||||
const bodyFatPct = profile.bodyFatPct ? Number(profile.bodyFatPct) : null;
|
||||
const bmr = bodyFatPct
|
||||
let bmr = bodyFatPct
|
||||
? calculateBmrKatchMcArdle(weightCurrentKg, bodyFatPct)
|
||||
: calculateBmr({
|
||||
weightKg: weightCurrentKg,
|
||||
@@ -44,7 +49,23 @@ function serialize(
|
||||
ageYears: profile.age,
|
||||
gender: profile.gender,
|
||||
});
|
||||
const conditions = parseConditions(profile.medicalConditions);
|
||||
bmr *= bmrAdjustmentForConditions(conditions);
|
||||
bmr *= bmrAdjustForDietHistory(profile.dietHistory as DietHistory | null);
|
||||
const tdee = calculateTdee(bmr, profile.activityLevel);
|
||||
const phaseInt =
|
||||
profile.currentPhase === 'INTENSIVE' ? 1 : profile.currentPhase === 'TRANSITION' ? 2 : 3;
|
||||
const { kcalTarget } = computeDailyKcalTarget({
|
||||
tdee,
|
||||
weightCurrentKg,
|
||||
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
||||
phase: phaseInt,
|
||||
});
|
||||
const macroTarget = macrosForPhase({
|
||||
kcalTarget,
|
||||
weightKg: weightCurrentKg,
|
||||
phase: phaseInt,
|
||||
});
|
||||
return {
|
||||
age: profile.age,
|
||||
gender: profile.gender,
|
||||
@@ -55,7 +76,7 @@ function serialize(
|
||||
activityLevel: profile.activityLevel,
|
||||
targetDate: profile.targetDate,
|
||||
targetWeeklyLossKg: profile.targetWeeklyLossKg,
|
||||
medicalConditions: parseConditions(profile.medicalConditions),
|
||||
medicalConditions: conditions,
|
||||
bodyFatPct,
|
||||
alertWeightKg: profile.alertWeightKg ? Number(profile.alertWeightKg) : null,
|
||||
dietHistory: profile.dietHistory,
|
||||
@@ -65,6 +86,10 @@ function serialize(
|
||||
tdee: Math.round(tdee),
|
||||
activityMultiplier: ACTIVITY_MULTIPLIERS[profile.activityLevel],
|
||||
bmrFormula: bodyFatPct ? 'katch_mcardle' : 'mifflin_st_jeor',
|
||||
kcalTarget: Math.round(kcalTarget),
|
||||
proteinTargetG: Math.round(macroTarget.proteinG),
|
||||
fatTargetG: Math.round(macroTarget.fatG),
|
||||
netCarbTargetG: Math.round(macroTarget.netCarbG),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
||||
|
||||
export const fastRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get('/me/fast-events', { preHandler: requireAuth() }, async (request) => {
|
||||
@@ -71,7 +72,17 @@ export const fastRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
return { event: { id: event.id, status: event.status } };
|
||||
let newlyUnlocked: string[] = [];
|
||||
if (event.status === 'COMPLETED') {
|
||||
const ach = await evaluateAndPersist(fastify.prisma, request.user!.id);
|
||||
newlyUnlocked = ach.newlyUnlocked;
|
||||
if (newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, request.user!.id, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
});
|
||||
}
|
||||
}
|
||||
return { event: { id: event.id, status: event.status }, newlyUnlocked };
|
||||
});
|
||||
|
||||
// PRD §5.3 — modalità "giorno libero". Mette in pausa i reminder fino a
|
||||
|
||||
@@ -2,6 +2,7 @@ import { weightEntryInputSchema } from '@ketopath/shared';
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { requireAuth } from '../../plugins/auth.js';
|
||||
import { evaluateAndPersist, notifyUnlocked } from '../achievements/service.js';
|
||||
|
||||
export const weightRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get('/me/weight-entries', { preHandler: requireAuth() }, async (request) => {
|
||||
@@ -57,8 +58,16 @@ export const weightRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
});
|
||||
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) } });
|
||||
const ach = await evaluateAndPersist(fastify.prisma, userId);
|
||||
if (ach.newlyUnlocked.length > 0) {
|
||||
void notifyUnlocked(fastify.prisma, userId, ach.newlyUnlocked).catch(() => {
|
||||
/* notifica best-effort */
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
entry: { id: entry.id, date: entry.date.toISOString().slice(0, 10) },
|
||||
newlyUnlocked: ach.newlyUnlocked,
|
||||
});
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user