Add additional lint rules
This commit is contained in:
parent
02d782c3da
commit
420b18eb78
67 changed files with 3686 additions and 1415 deletions
|
|
@ -26,13 +26,79 @@ export default tseslint.config(
|
|||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/member-ordering': [
|
||||
'error',
|
||||
{
|
||||
default: [
|
||||
'public-static-field',
|
||||
'protected-static-field',
|
||||
'private-static-field',
|
||||
'public-instance-field',
|
||||
'protected-instance-field',
|
||||
'private-instance-field',
|
||||
'constructor',
|
||||
'public-instance-method',
|
||||
'protected-instance-method',
|
||||
'private-instance-method',
|
||||
],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'error',
|
||||
{
|
||||
allowExpressions: true,
|
||||
allowTypedFunctionExpressions: true,
|
||||
allowHigherOrderFunctions: true,
|
||||
allowDirectConstAssertionInArrowFunctions: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-unsafe-return': 'error',
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'default',
|
||||
format: ['camelCase'],
|
||||
leadingUnderscore: 'allow',
|
||||
trailingUnderscore: 'allow',
|
||||
},
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['camelCase', 'UPPER_CASE', 'PascalCase'],
|
||||
leadingUnderscore: 'allow',
|
||||
trailingUnderscore: 'allow',
|
||||
},
|
||||
{
|
||||
selector: 'typeLike',
|
||||
format: ['PascalCase'],
|
||||
},
|
||||
{
|
||||
selector: 'interface',
|
||||
format: ['PascalCase'],
|
||||
custom: {
|
||||
regex: '^I[A-Z]',
|
||||
match: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'objectLiteralProperty',
|
||||
format: null,
|
||||
},
|
||||
{
|
||||
selector: 'objectLiteralMethod',
|
||||
format: null,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Relax some rules in test files
|
||||
files: ['**/*.test.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/explicit-member-accessibility': 'off',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-function-return-type */
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { CabinetEventModel } from '../../schemas/cabinet-event.schema.js';
|
||||
import type { CabinetEventDocument } from '../../schemas/cabinet-event.schema.js';
|
||||
import type {
|
||||
CabinetEventType,
|
||||
CabinetEventSourceType,
|
||||
|
|
@ -28,17 +29,24 @@ export interface CreateCabinetEventData {
|
|||
}
|
||||
|
||||
export class CabinetEventsRepository {
|
||||
public async create(data: CreateCabinetEventData) {
|
||||
public async create(data: CreateCabinetEventData): Promise<CabinetEventDocument> {
|
||||
const event = new CabinetEventModel(data);
|
||||
const saved = await event.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as CabinetEventDocument;
|
||||
}
|
||||
|
||||
public async createMany(events: CreateCabinetEventData[]) {
|
||||
return CabinetEventModel.insertMany(events);
|
||||
public async createMany(events: CreateCabinetEventData[]): Promise<CabinetEventDocument[]> {
|
||||
const docs = await CabinetEventModel.insertMany(events);
|
||||
return docs as unknown as CabinetEventDocument[];
|
||||
}
|
||||
|
||||
public async findByHousehold(householdId: string, query: CabinetEventQueryInput) {
|
||||
public async findByHousehold(
|
||||
householdId: string,
|
||||
query: CabinetEventQueryInput,
|
||||
): Promise<{
|
||||
data: CabinetEventDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
const filter: Record<string, unknown> = { householdId };
|
||||
|
||||
if (query.medicineId) filter['medicineId'] = query.medicineId;
|
||||
|
|
@ -67,14 +75,20 @@ export class CabinetEventsRepository {
|
|||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
return {
|
||||
data: data as unknown as CabinetEventDocument[],
|
||||
pagination: { cursor: hasMore ? cursor : null, hasMore },
|
||||
};
|
||||
}
|
||||
|
||||
public async findByCabinetItem(
|
||||
householdId: string,
|
||||
cabinetItemId: string,
|
||||
query: { cursor?: string; limit: number },
|
||||
) {
|
||||
): Promise<{
|
||||
data: CabinetEventDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
const filter: Record<string, unknown> = { householdId, cabinetItemId };
|
||||
|
||||
if (query.cursor) {
|
||||
|
|
@ -94,10 +108,31 @@ export class CabinetEventsRepository {
|
|||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
return {
|
||||
data: data as unknown as CabinetEventDocument[],
|
||||
pagination: { cursor: hasMore ? cursor : null, hasMore },
|
||||
};
|
||||
}
|
||||
|
||||
public async getSpendingSummary(householdId: string, query: SpendingSummaryQueryInput) {
|
||||
public async getSpendingSummary(
|
||||
householdId: string,
|
||||
query: SpendingSummaryQueryInput,
|
||||
): Promise<{
|
||||
totalSpent: number;
|
||||
currency: string | null;
|
||||
byMedicine: Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
totalSpent: number;
|
||||
totalQuantity: number;
|
||||
avgUnitPrice: number;
|
||||
purchaseCount: number;
|
||||
}>;
|
||||
byPeriod: Array<{
|
||||
period: string;
|
||||
totalSpent: number;
|
||||
}>;
|
||||
}> {
|
||||
const match: Record<string, unknown> = {
|
||||
householdId,
|
||||
eventType: 'purchased',
|
||||
|
|
@ -114,7 +149,22 @@ export class CabinetEventsRepository {
|
|||
const dateFormat =
|
||||
query.period === 'year' ? '%Y' : query.period === 'quarter' ? '%Y-Q%q' : '%Y-%m';
|
||||
|
||||
const [byMedicine, byPeriod] = await Promise.all([
|
||||
interface MedicineSpendingSummaryGroup {
|
||||
_id: string;
|
||||
medicineName: string;
|
||||
totalSpent: number;
|
||||
totalQuantity: number;
|
||||
purchaseCount: number;
|
||||
currency: string | null;
|
||||
avgUnitPrice: number;
|
||||
}
|
||||
|
||||
interface PeriodSpendingSummaryGroup {
|
||||
_id: string;
|
||||
totalSpent: number;
|
||||
}
|
||||
|
||||
const [byMedicineRaw, byPeriodRaw] = await Promise.all([
|
||||
CabinetEventModel.aggregate([
|
||||
{ $match: match },
|
||||
{
|
||||
|
|
@ -152,36 +202,47 @@ export class CabinetEventsRepository {
|
|||
]).exec(),
|
||||
]);
|
||||
|
||||
const byMedicine = byMedicineRaw as unknown as MedicineSpendingSummaryGroup[];
|
||||
const byPeriod = byPeriodRaw as unknown as PeriodSpendingSummaryGroup[];
|
||||
|
||||
const totalSpent = byMedicine.reduce(
|
||||
(sum: number, m: Record<string, unknown>) => sum + (m.totalSpent as number),
|
||||
(sum: number, m: MedicineSpendingSummaryGroup) => sum + m.totalSpent,
|
||||
0,
|
||||
);
|
||||
const currency =
|
||||
byMedicine.length > 0 ? ((byMedicine[0].currency as string | null) ?? null) : null;
|
||||
const currency = byMedicine.length > 0 ? (byMedicine[0].currency ?? null) : null;
|
||||
|
||||
return {
|
||||
totalSpent,
|
||||
currency,
|
||||
byMedicine: byMedicine.map((m: Record<string, unknown>) => ({
|
||||
medicineId: m._id as string,
|
||||
medicineName: m.medicineName as string,
|
||||
totalSpent: m.totalSpent as number,
|
||||
totalQuantity: m.totalQuantity as number,
|
||||
avgUnitPrice: m.avgUnitPrice as number,
|
||||
purchaseCount: m.purchaseCount as number,
|
||||
byMedicine: byMedicine.map((m: MedicineSpendingSummaryGroup) => ({
|
||||
medicineId: m._id,
|
||||
medicineName: m.medicineName,
|
||||
totalSpent: m.totalSpent,
|
||||
totalQuantity: m.totalQuantity,
|
||||
avgUnitPrice: m.avgUnitPrice,
|
||||
purchaseCount: m.purchaseCount,
|
||||
})),
|
||||
byPeriod: byPeriod.map((p: Record<string, unknown>) => ({
|
||||
period: p._id as string,
|
||||
totalSpent: p.totalSpent as number,
|
||||
byPeriod: byPeriod.map((p: PeriodSpendingSummaryGroup) => ({
|
||||
period: p._id,
|
||||
totalSpent: p.totalSpent,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
public async getAvgUnitPriceByMedicine(householdId: string, medicineIds: string[]) {
|
||||
public async getAvgUnitPriceByMedicine(
|
||||
householdId: string,
|
||||
medicineIds: string[],
|
||||
): Promise<Map<string, { avgUnitPrice: number; currency: string | null }>> {
|
||||
if (medicineIds.length === 0)
|
||||
return new Map<string, { avgUnitPrice: number; currency: string | null }>();
|
||||
|
||||
const results = await CabinetEventModel.aggregate([
|
||||
interface AvgUnitPriceGroup {
|
||||
_id: string;
|
||||
avgUnitPrice: number;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
const resultsRaw = await CabinetEventModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
householdId,
|
||||
|
|
@ -211,11 +272,12 @@ export class CabinetEventsRepository {
|
|||
},
|
||||
]).exec();
|
||||
|
||||
const results = resultsRaw as unknown as AvgUnitPriceGroup[];
|
||||
const map = new Map<string, { avgUnitPrice: number; currency: string | null }>();
|
||||
for (const r of results) {
|
||||
map.set(r._id as string, {
|
||||
avgUnitPrice: r.avgUnitPrice as number,
|
||||
currency: (r.currency as string | null) ?? null,
|
||||
map.set(r._id, {
|
||||
avgUnitPrice: r.avgUnitPrice,
|
||||
currency: r.currency ?? null,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ import {
|
|||
CabinetEventListResponseSchema,
|
||||
SpendingSummaryQuerySchema,
|
||||
SpendingSummaryResponseSchema,
|
||||
type CabinetEventQueryInput,
|
||||
type SpendingSummaryQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { CabinetEventsRepository } from './cabinet-events.repository.js';
|
||||
import { CabinetEventsService } from './cabinet-events.service.js';
|
||||
import type { CabinetEventDocument } from '../../schemas/cabinet-event.schema.js';
|
||||
|
||||
type AnyCabinetEventDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedCabinetEventResponse {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
userId: string;
|
||||
cabinetItemId: string;
|
||||
|
|
@ -22,31 +25,38 @@ type AnyCabinetEventDoc = {
|
|||
quantity: number;
|
||||
quantityBefore: number;
|
||||
quantityAfter: number;
|
||||
unitPrice?: number | null;
|
||||
totalPrice?: number | null;
|
||||
currency?: string | null;
|
||||
storeId?: string | null;
|
||||
storeName?: string | null;
|
||||
unitPrice?: number;
|
||||
totalPrice?: number;
|
||||
currency?: string;
|
||||
storeId?: string;
|
||||
storeName?: string;
|
||||
sourceType: string;
|
||||
sourceId?: string | null;
|
||||
reason?: string | null;
|
||||
notes?: string | null;
|
||||
createdAt: string | Date | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
sourceId?: string;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
return v.toISOString();
|
||||
}
|
||||
function toCabinetEventResponse(doc: CabinetEventDocument): SerializedCabinetEventResponse {
|
||||
const docAny = doc as unknown as {
|
||||
unitPrice?: number | null;
|
||||
totalPrice?: number | null;
|
||||
currency?: string | null;
|
||||
storeId?: string | null;
|
||||
storeName?: string | null;
|
||||
sourceId?: string | null;
|
||||
reason?: string | null;
|
||||
notes?: string | null;
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
};
|
||||
|
||||
function toCabinetEventResponse(doc: AnyCabinetEventDoc) {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
const createdAtStr =
|
||||
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
|
||||
? docAny.createdAt.toISOString()
|
||||
: String(docAny.createdAt || '');
|
||||
|
||||
const response: SerializedCabinetEventResponse = {
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
userId: doc.userId,
|
||||
cabinetItemId: doc.cabinetItemId,
|
||||
|
|
@ -56,17 +66,20 @@ function toCabinetEventResponse(doc: AnyCabinetEventDoc) {
|
|||
quantity: doc.quantity,
|
||||
quantityBefore: doc.quantityBefore,
|
||||
quantityAfter: doc.quantityAfter,
|
||||
...(doc.unitPrice != null ? { unitPrice: doc.unitPrice } : {}),
|
||||
...(doc.totalPrice != null ? { totalPrice: doc.totalPrice } : {}),
|
||||
...(doc.currency ? { currency: doc.currency } : {}),
|
||||
...(doc.storeId ? { storeId: doc.storeId } : {}),
|
||||
...(doc.storeName ? { storeName: doc.storeName } : {}),
|
||||
sourceType: doc.sourceType,
|
||||
...(doc.sourceId ? { sourceId: doc.sourceId } : {}),
|
||||
...(doc.reason ? { reason: doc.reason } : {}),
|
||||
...(doc.notes ? { notes: doc.notes } : {}),
|
||||
createdAt: toIso(doc.createdAt),
|
||||
createdAt: createdAtStr,
|
||||
};
|
||||
|
||||
if (docAny.unitPrice != null) response.unitPrice = docAny.unitPrice;
|
||||
if (docAny.totalPrice != null) response.totalPrice = docAny.totalPrice;
|
||||
if (docAny.currency) response.currency = docAny.currency;
|
||||
if (docAny.storeId) response.storeId = docAny.storeId;
|
||||
if (docAny.storeName) response.storeName = docAny.storeName;
|
||||
if (docAny.sourceId) response.sourceId = docAny.sourceId;
|
||||
if (docAny.reason) response.reason = docAny.reason;
|
||||
if (docAny.notes) response.notes = docAny.notes;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
|
|
@ -97,7 +110,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetEventsService');
|
||||
const result = await service.listEvents(request.params.householdId, request.query);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as CabinetEventQueryInput;
|
||||
const result = await service.listEvents(params.householdId, query);
|
||||
return reply.send({
|
||||
data: result.data.map(toCabinetEventResponse),
|
||||
pagination: result.pagination,
|
||||
|
|
@ -119,10 +134,12 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetEventsService');
|
||||
const params = request.params as { householdId: string; cabinetItemId: string };
|
||||
const query = request.query as { cursor?: string; limit: number };
|
||||
const result = await service.getEventsByItem(
|
||||
request.params.householdId,
|
||||
request.params.cabinetItemId,
|
||||
request.query,
|
||||
params.householdId,
|
||||
params.cabinetItemId,
|
||||
query,
|
||||
);
|
||||
return reply.send({
|
||||
data: result.data.map(toCabinetEventResponse),
|
||||
|
|
@ -142,7 +159,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetEventsService');
|
||||
const summary = await service.getSpendingSummary(request.params.householdId, request.query);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as SpendingSummaryQueryInput;
|
||||
const summary = await service.getSpendingSummary(params.householdId, query);
|
||||
return reply.send(summary);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
CabinetEventsRepository,
|
||||
CreateCabinetEventData,
|
||||
} from './cabinet-events.repository.js';
|
||||
import type { CabinetEventDocument } from '../../schemas/cabinet-event.schema.js';
|
||||
import type { CabinetEventQueryInput, SpendingSummaryQueryInput } from '@meshitrack/shared';
|
||||
|
||||
interface Deps {
|
||||
|
|
@ -15,16 +16,22 @@ export class CabinetEventsService {
|
|||
this.cabinetEventsRepository = cabinetEventsRepository;
|
||||
}
|
||||
|
||||
public async logEvent(data: CreateCabinetEventData) {
|
||||
public async logEvent(data: CreateCabinetEventData): Promise<CabinetEventDocument> {
|
||||
return this.cabinetEventsRepository.create(data);
|
||||
}
|
||||
|
||||
public async logEvents(events: CreateCabinetEventData[]) {
|
||||
public async logEvents(events: CreateCabinetEventData[]): Promise<CabinetEventDocument[]> {
|
||||
if (events.length === 0) return [];
|
||||
return this.cabinetEventsRepository.createMany(events);
|
||||
}
|
||||
|
||||
public async listEvents(householdId: string, query: CabinetEventQueryInput) {
|
||||
public async listEvents(
|
||||
householdId: string,
|
||||
query: CabinetEventQueryInput,
|
||||
): Promise<{
|
||||
data: CabinetEventDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
return this.cabinetEventsRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
|
|
@ -32,15 +39,39 @@ export class CabinetEventsService {
|
|||
householdId: string,
|
||||
cabinetItemId: string,
|
||||
query: { cursor?: string; limit: number },
|
||||
) {
|
||||
): Promise<{
|
||||
data: CabinetEventDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
return this.cabinetEventsRepository.findByCabinetItem(householdId, cabinetItemId, query);
|
||||
}
|
||||
|
||||
public async getSpendingSummary(householdId: string, query: SpendingSummaryQueryInput) {
|
||||
public async getSpendingSummary(
|
||||
householdId: string,
|
||||
query: SpendingSummaryQueryInput,
|
||||
): Promise<{
|
||||
totalSpent: number;
|
||||
currency: string | null;
|
||||
byMedicine: Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
totalSpent: number;
|
||||
totalQuantity: number;
|
||||
avgUnitPrice: number;
|
||||
purchaseCount: number;
|
||||
}>;
|
||||
byPeriod: Array<{
|
||||
period: string;
|
||||
totalSpent: number;
|
||||
}>;
|
||||
}> {
|
||||
return this.cabinetEventsRepository.getSpendingSummary(householdId, query);
|
||||
}
|
||||
|
||||
public async getAvgUnitPrices(householdId: string, medicineIds: string[]) {
|
||||
public async getAvgUnitPrices(
|
||||
householdId: string,
|
||||
medicineIds: string[],
|
||||
): Promise<Map<string, { avgUnitPrice: number; currency: string | null }>> {
|
||||
return this.cabinetEventsRepository.getAvgUnitPriceByMedicine(householdId, medicineIds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { CabinetItemModel } from '../../schemas/cabinet-item.schema.js';
|
||||
import type { CabinetItemDocument } from '../../schemas/cabinet-item.schema.js';
|
||||
import type {
|
||||
CabinetItemStatus,
|
||||
CreateCabinetItemInput,
|
||||
|
|
@ -14,7 +15,13 @@ interface FindByHouseholdQuery {
|
|||
}
|
||||
|
||||
export class CabinetRepository {
|
||||
public async findByHousehold(householdId: string, query: FindByHouseholdQuery) {
|
||||
public async findByHousehold(
|
||||
householdId: string,
|
||||
query: FindByHouseholdQuery,
|
||||
): Promise<{
|
||||
data: CabinetItemDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
const filter: Record<string, unknown> = { householdId, isDeleted: false };
|
||||
|
||||
if (query.medicineId) filter['medicineId'] = query.medicineId;
|
||||
|
|
@ -44,15 +51,21 @@ export class CabinetRepository {
|
|||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
return {
|
||||
data: data as unknown as CabinetItemDocument[],
|
||||
pagination: { cursor: hasMore ? cursor : null, hasMore },
|
||||
};
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return CabinetItemModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
|
||||
public async findById(id: string, householdId: string): Promise<CabinetItemDocument | null> {
|
||||
const doc = await CabinetItemModel.findOne({ _id: id, householdId, isDeleted: false })
|
||||
.lean()
|
||||
.exec();
|
||||
return doc as unknown as CabinetItemDocument | null;
|
||||
}
|
||||
|
||||
public async getAggregateSummary(householdId: string) {
|
||||
return CabinetItemModel.aggregate([
|
||||
public async getAggregateSummary(householdId: string): Promise<Array<Record<string, unknown>>> {
|
||||
const results = await CabinetItemModel.aggregate([
|
||||
{ $match: { householdId, isDeleted: false, status: 'active' } },
|
||||
{
|
||||
$group: {
|
||||
|
|
@ -69,6 +82,7 @@ export class CabinetRepository {
|
|||
},
|
||||
{ $sort: { medicineName: 1, medicineForm: 1, medicineStrength: 1, _id: 1 } },
|
||||
]).exec();
|
||||
return results as unknown as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
public async create(
|
||||
|
|
@ -83,21 +97,30 @@ export class CabinetRepository {
|
|||
},
|
||||
householdId: string,
|
||||
createdBy: string,
|
||||
) {
|
||||
): Promise<CabinetItemDocument> {
|
||||
const item = new CabinetItemModel({ ...data, householdId, createdBy });
|
||||
const saved = await item.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as CabinetItemDocument;
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateCabinetItemInput) {
|
||||
return CabinetItemModel.findOneAndUpdate(
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateCabinetItemInput,
|
||||
): Promise<CabinetItemDocument | null> {
|
||||
const doc = await CabinetItemModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as CabinetItemDocument | null;
|
||||
}
|
||||
|
||||
public async adjustQuantity(id: string, householdId: string, delta: number) {
|
||||
public async adjustQuantity(
|
||||
id: string,
|
||||
householdId: string,
|
||||
delta: number,
|
||||
): Promise<CabinetItemDocument | null> {
|
||||
const item = await CabinetItemModel.findOne({
|
||||
_id: id,
|
||||
householdId,
|
||||
|
|
@ -112,18 +135,22 @@ export class CabinetRepository {
|
|||
const newStatus =
|
||||
newQuantity === 0 ? 'depleted' : item.status === 'depleted' ? 'active' : item.status;
|
||||
|
||||
return CabinetItemModel.findOneAndUpdate(
|
||||
const doc = await CabinetItemModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: { quantity: newQuantity, status: newStatus } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as CabinetItemDocument | null;
|
||||
}
|
||||
|
||||
public async findExpiringSoon(householdId: string, withinDays: number) {
|
||||
public async findExpiringSoon(
|
||||
householdId: string,
|
||||
withinDays: number,
|
||||
): Promise<CabinetItemDocument[]> {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() + withinDays);
|
||||
|
||||
return CabinetItemModel.find({
|
||||
const docs = await CabinetItemModel.find({
|
||||
householdId,
|
||||
isDeleted: false,
|
||||
status: 'active',
|
||||
|
|
@ -132,30 +159,36 @@ export class CabinetRepository {
|
|||
.sort({ expirationDate: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return docs as unknown as CabinetItemDocument[];
|
||||
}
|
||||
|
||||
public async countByMedicineId(medicineId: string): Promise<number> {
|
||||
return CabinetItemModel.countDocuments({ medicineId, isDeleted: false }).exec();
|
||||
}
|
||||
|
||||
public async softDelete(id: string, householdId: string) {
|
||||
return CabinetItemModel.findOneAndUpdate(
|
||||
public async softDelete(id: string, householdId: string): Promise<CabinetItemDocument | null> {
|
||||
const doc = await CabinetItemModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: { isDeleted: true } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as CabinetItemDocument | null;
|
||||
}
|
||||
|
||||
public async discard(id: string, householdId: string) {
|
||||
return CabinetItemModel.findOneAndUpdate(
|
||||
public async discard(id: string, householdId: string): Promise<CabinetItemDocument | null> {
|
||||
const doc = await CabinetItemModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: { quantity: 0, status: 'depleted', isDeleted: true } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as CabinetItemDocument | null;
|
||||
}
|
||||
|
||||
public async findActiveByMedicineForFEFO(householdId: string, medicineId: string) {
|
||||
return CabinetItemModel.find({
|
||||
public async findActiveByMedicineForFEFO(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
): Promise<CabinetItemDocument[]> {
|
||||
const docs = await CabinetItemModel.find({
|
||||
householdId,
|
||||
medicineId,
|
||||
isDeleted: false,
|
||||
|
|
@ -165,5 +198,6 @@ export class CabinetRepository {
|
|||
.sort({ expirationDate: 1, _id: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return docs as unknown as CabinetItemDocument[];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,83 +11,106 @@ import {
|
|||
CabinetItemListResponseSchema,
|
||||
CabinetSummaryResponseSchema,
|
||||
DiscardCabinetItemSchema,
|
||||
type CreateCabinetItemInput,
|
||||
type UpdateCabinetItemInput,
|
||||
type CabinetQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { CabinetRepository } from './cabinet.repository.js';
|
||||
import { CabinetService } from './cabinet.service.js';
|
||||
import type { CabinetItemDocument } from '../../schemas/cabinet-item.schema.js';
|
||||
|
||||
type AnyCabinetDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedCabinetItemResponse {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: string;
|
||||
medicineForm: string;
|
||||
medicineProductId?: string | null;
|
||||
medicineProductBrand?: string | null;
|
||||
concentration?: number | null;
|
||||
concentrationUnit?: string | null;
|
||||
medicineProductId?: string;
|
||||
medicineProductBrand?: string;
|
||||
concentration?: number;
|
||||
concentrationUnit?: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
expirationDate?: Date | string | null;
|
||||
expirationDate?: string;
|
||||
status: string;
|
||||
purchaseDate?: Date | string | null;
|
||||
unitPrice?: number | null;
|
||||
totalPrice?: number | null;
|
||||
currency?: string | null;
|
||||
storeId?: string | null;
|
||||
storeName?: string | null;
|
||||
notes?: string | null;
|
||||
purchaseDate?: string;
|
||||
unitPrice?: number;
|
||||
totalPrice?: number;
|
||||
currency?: string;
|
||||
storeId?: string;
|
||||
storeName?: string;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
return v.toISOString();
|
||||
}
|
||||
function toCabinetItemResponse(doc: CabinetItemDocument): SerializedCabinetItemResponse {
|
||||
const docAny = doc as unknown as {
|
||||
medicineProductId?: string | null;
|
||||
medicineProductBrand?: string | null;
|
||||
concentration?: number | null;
|
||||
concentrationUnit?: string | null;
|
||||
expirationDate?: Date | string | null;
|
||||
purchaseDate?: Date | string | null;
|
||||
unitPrice?: number | null;
|
||||
totalPrice?: number | null;
|
||||
currency?: string | null;
|
||||
storeId?: string | null;
|
||||
storeName?: string | null;
|
||||
notes?: string | null;
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
};
|
||||
|
||||
function toOptIso(v: Date | string | null | undefined): string | undefined {
|
||||
/* v8 ignore next */
|
||||
if (!v) return undefined;
|
||||
if (typeof v === 'string') return v;
|
||||
return v.toISOString();
|
||||
}
|
||||
const getIsoStr = (d: Date | string | null | undefined): string | undefined => {
|
||||
if (!d) return undefined;
|
||||
if (typeof d === 'string') return d;
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
function toCabinetItemResponse(doc: AnyCabinetDoc): z.infer<typeof CabinetItemResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
const createdAtStr =
|
||||
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
|
||||
? docAny.createdAt.toISOString()
|
||||
: String(docAny.createdAt || '');
|
||||
|
||||
const updatedAtStr =
|
||||
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
|
||||
? docAny.updatedAt.toISOString()
|
||||
: String(docAny.updatedAt || '');
|
||||
|
||||
const response: SerializedCabinetItemResponse = {
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
medicineId: doc.medicineId,
|
||||
medicineName: doc.medicineName,
|
||||
medicineStrength: doc.medicineStrength,
|
||||
medicineStrengthUnit: doc.medicineStrengthUnit,
|
||||
medicineForm: doc.medicineForm,
|
||||
...(doc.medicineProductId ? { medicineProductId: doc.medicineProductId } : {}),
|
||||
...(doc.medicineProductBrand ? { medicineProductBrand: doc.medicineProductBrand } : {}),
|
||||
...(doc.concentration != null ? { concentration: doc.concentration } : {}),
|
||||
...(doc.concentrationUnit ? { concentrationUnit: doc.concentrationUnit } : {}),
|
||||
quantity: doc.quantity,
|
||||
unit: doc.unit,
|
||||
...(doc.expirationDate ? { expirationDate: toOptIso(doc.expirationDate) } : {}),
|
||||
status: doc.status,
|
||||
...(doc.purchaseDate ? { purchaseDate: toOptIso(doc.purchaseDate) } : {}),
|
||||
...(doc.unitPrice != null ? { unitPrice: doc.unitPrice } : {}),
|
||||
...(doc.totalPrice != null ? { totalPrice: doc.totalPrice } : {}),
|
||||
...(doc.currency ? { currency: doc.currency } : {}),
|
||||
...(doc.storeId ? { storeId: doc.storeId } : {}),
|
||||
...(doc.storeName ? { storeName: doc.storeName } : {}),
|
||||
...(doc.notes ? { notes: doc.notes } : {}),
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
createdAt: createdAtStr,
|
||||
updatedAt: updatedAtStr,
|
||||
};
|
||||
|
||||
if (docAny.medicineProductId) response.medicineProductId = docAny.medicineProductId;
|
||||
if (docAny.medicineProductBrand) response.medicineProductBrand = docAny.medicineProductBrand;
|
||||
if (docAny.concentration != null) response.concentration = docAny.concentration;
|
||||
if (docAny.concentrationUnit) response.concentrationUnit = docAny.concentrationUnit;
|
||||
if (docAny.expirationDate) response.expirationDate = getIsoStr(docAny.expirationDate);
|
||||
if (docAny.purchaseDate) response.purchaseDate = getIsoStr(docAny.purchaseDate);
|
||||
if (docAny.unitPrice != null) response.unitPrice = docAny.unitPrice;
|
||||
if (docAny.totalPrice != null) response.totalPrice = docAny.totalPrice;
|
||||
if (docAny.currency) response.currency = docAny.currency;
|
||||
if (docAny.storeId) response.storeId = docAny.storeId;
|
||||
if (docAny.storeName) response.storeName = docAny.storeName;
|
||||
if (docAny.notes) response.notes = docAny.notes;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
|
|
@ -118,7 +141,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as CabinetQueryInput;
|
||||
const result = await service.list(params.householdId, query);
|
||||
return reply.send({
|
||||
data: result.data.map(toCabinetItemResponse),
|
||||
pagination: result.pagination,
|
||||
|
|
@ -136,7 +161,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
const data = await service.getSummary(request.params.householdId);
|
||||
const params = request.params as { householdId: string };
|
||||
const data = await service.getSummary(params.householdId);
|
||||
return reply.send({ data });
|
||||
},
|
||||
});
|
||||
|
|
@ -156,7 +182,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
const items = await service.getExpiringSoon(request.params.householdId, request.query.days);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as { days: number };
|
||||
const items = await service.getExpiringSoon(params.householdId, query.days);
|
||||
return reply.send({ data: items.map(toCabinetItemResponse) });
|
||||
},
|
||||
});
|
||||
|
|
@ -171,7 +199,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
const item = await service.getById(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const item = await service.getById(params.id, params.householdId);
|
||||
return reply.send(toCabinetItemResponse(item));
|
||||
},
|
||||
});
|
||||
|
|
@ -187,11 +216,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
const item = await service.addItem(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as CreateCabinetItemInput;
|
||||
const item = await service.addItem(body, params.householdId, request.user.keycloakId);
|
||||
return reply.status(201).send(toCabinetItemResponse(item));
|
||||
},
|
||||
});
|
||||
|
|
@ -207,10 +234,12 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as UpdateCabinetItemInput;
|
||||
const item = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
params.id,
|
||||
params.householdId,
|
||||
body,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(toCabinetItemResponse(item));
|
||||
|
|
@ -228,12 +257,14 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as { delta: number; reason?: string };
|
||||
const item = await service.adjustQuantity(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body.delta,
|
||||
params.id,
|
||||
params.householdId,
|
||||
body.delta,
|
||||
request.user.keycloakId,
|
||||
request.body.reason,
|
||||
body.reason,
|
||||
);
|
||||
return reply.send(toCabinetItemResponse(item));
|
||||
},
|
||||
|
|
@ -249,11 +280,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
await service.delete(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
await service.delete(params.id, params.householdId, request.user.keycloakId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
|
|
@ -269,12 +297,14 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('cabinetService');
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as { reason: string; notes?: string };
|
||||
const item = await service.discard(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
params.id,
|
||||
params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.body.reason,
|
||||
request.body.notes,
|
||||
body.reason,
|
||||
body.notes,
|
||||
);
|
||||
return reply.send(toCabinetItemResponse(item));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { CabinetRepository } from './cabinet.repository.js';
|
|||
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
|
||||
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
|
||||
import type { CabinetItemDocument } from '../../schemas/cabinet-item.schema.js';
|
||||
import { CabinetEventType, CabinetEventSourceType } from '@meshitrack/shared';
|
||||
import type {
|
||||
CreateCabinetItemInput,
|
||||
|
|
@ -17,6 +18,18 @@ interface Deps {
|
|||
cabinetEventsService: CabinetEventsService;
|
||||
}
|
||||
|
||||
interface CabinetSummaryItem {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: string;
|
||||
medicineForm: string;
|
||||
totalQuantity: number;
|
||||
unit: string;
|
||||
earliestExpiry: string | null;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export class CabinetService {
|
||||
private readonly cabinetRepository: CabinetRepository;
|
||||
private readonly medicinesRepository: MedicinesRepository;
|
||||
|
|
@ -35,11 +48,17 @@ export class CabinetService {
|
|||
this.cabinetEventsService = cabinetEventsService;
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: CabinetQueryInput) {
|
||||
public async list(
|
||||
householdId: string,
|
||||
query: CabinetQueryInput,
|
||||
): Promise<{
|
||||
data: CabinetItemDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
return this.cabinetRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
public async getById(id: string, householdId: string): Promise<CabinetItemDocument> {
|
||||
const item = await this.cabinetRepository.findById(id, householdId);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Cabinet item not found');
|
||||
|
|
@ -47,7 +66,7 @@ export class CabinetService {
|
|||
return item;
|
||||
}
|
||||
|
||||
public async getSummary(householdId: string) {
|
||||
public async getSummary(householdId: string): Promise<CabinetSummaryItem[]> {
|
||||
const results = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||
return results.map((r: Record<string, unknown>) => ({
|
||||
medicineId: r._id as string,
|
||||
|
|
@ -62,7 +81,11 @@ export class CabinetService {
|
|||
}));
|
||||
}
|
||||
|
||||
public async addItem(data: CreateCabinetItemInput, householdId: string, createdBy: string) {
|
||||
public async addItem(
|
||||
data: CreateCabinetItemInput,
|
||||
householdId: string,
|
||||
createdBy: string,
|
||||
): Promise<CabinetItemDocument> {
|
||||
const medicine = await this.medicinesRepository.findById(data.medicineId, householdId);
|
||||
if (!medicine) {
|
||||
throw new NotFoundError('Medicine not found');
|
||||
|
|
@ -125,7 +148,7 @@ export class CabinetService {
|
|||
householdId: string,
|
||||
data: UpdateCabinetItemInput,
|
||||
userId: string,
|
||||
) {
|
||||
): Promise<CabinetItemDocument> {
|
||||
const existing = await this.getById(id, householdId);
|
||||
const updated = await this.cabinetRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Cabinet item not found');
|
||||
|
|
@ -154,7 +177,7 @@ export class CabinetService {
|
|||
delta: number,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
) {
|
||||
): Promise<CabinetItemDocument> {
|
||||
if (delta === 0) {
|
||||
throw new BadRequestError('Delta must be non-zero');
|
||||
}
|
||||
|
|
@ -180,11 +203,18 @@ export class CabinetService {
|
|||
return updated;
|
||||
}
|
||||
|
||||
public async getExpiringSoon(householdId: string, withinDays: number) {
|
||||
public async getExpiringSoon(
|
||||
householdId: string,
|
||||
withinDays: number,
|
||||
): Promise<CabinetItemDocument[]> {
|
||||
return this.cabinetRepository.findExpiringSoon(householdId, withinDays);
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string, userId: string) {
|
||||
public async delete(
|
||||
id: string,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<CabinetItemDocument> {
|
||||
const existing = await this.getById(id, householdId);
|
||||
const deleted = await this.cabinetRepository.softDelete(id, householdId);
|
||||
if (!deleted) throw new NotFoundError('Cabinet item not found');
|
||||
|
|
@ -211,7 +241,7 @@ export class CabinetService {
|
|||
userId: string,
|
||||
reason: string,
|
||||
notes?: string,
|
||||
) {
|
||||
): Promise<CabinetItemDocument> {
|
||||
const existing = await this.getById(id, householdId);
|
||||
if (existing.quantity === 0) {
|
||||
throw new BadRequestError('Cannot discard an item with zero quantity');
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import type mongoose from 'mongoose';
|
||||
import { HouseholdModel } from '../../schemas/household.schema.js';
|
||||
import type { HouseholdDocument } from '../../schemas/household.schema.js';
|
||||
import type { CreateHouseholdInput, UpdateHouseholdInput } from '@meshitrack/shared';
|
||||
import { HouseholdRole } from '@meshitrack/shared';
|
||||
|
||||
export class HouseholdsRepository {
|
||||
public async findById(id: string) {
|
||||
return HouseholdModel.findById(id).lean().exec();
|
||||
public async findById(id: string): Promise<HouseholdDocument | null> {
|
||||
const doc = await HouseholdModel.findById(id).lean().exec();
|
||||
return doc as unknown as HouseholdDocument | null;
|
||||
}
|
||||
|
||||
public async findByInviteCode(inviteCode: string) {
|
||||
return HouseholdModel.findOne({ inviteCode }).lean().exec();
|
||||
public async findByInviteCode(inviteCode: string): Promise<HouseholdDocument | null> {
|
||||
const doc = await HouseholdModel.findOne({ inviteCode }).lean().exec();
|
||||
return doc as unknown as HouseholdDocument | null;
|
||||
}
|
||||
|
||||
public async create(
|
||||
|
|
@ -17,7 +20,7 @@ export class HouseholdsRepository {
|
|||
ownerUserId: string,
|
||||
inviteCode: string,
|
||||
session?: mongoose.ClientSession,
|
||||
) {
|
||||
): Promise<HouseholdDocument> {
|
||||
const household = new HouseholdModel({
|
||||
...data,
|
||||
ownerUserId,
|
||||
|
|
@ -25,11 +28,16 @@ export class HouseholdsRepository {
|
|||
members: [{ userId: ownerUserId, role: HouseholdRole.OWNER, joinedAt: new Date() }],
|
||||
});
|
||||
const saved = await household.save({ session });
|
||||
return saved.toObject();
|
||||
return saved.toObject() as HouseholdDocument;
|
||||
}
|
||||
|
||||
public async update(id: string, data: UpdateHouseholdInput) {
|
||||
return HouseholdModel.findByIdAndUpdate(id, { $set: data }, { new: true, lean: true }).exec();
|
||||
public async update(id: string, data: UpdateHouseholdInput): Promise<HouseholdDocument | null> {
|
||||
const doc = await HouseholdModel.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as HouseholdDocument | null;
|
||||
}
|
||||
|
||||
public async addMember(
|
||||
|
|
@ -37,19 +45,21 @@ export class HouseholdsRepository {
|
|||
userId: string,
|
||||
role: HouseholdRole,
|
||||
session?: mongoose.ClientSession,
|
||||
) {
|
||||
return HouseholdModel.findByIdAndUpdate(
|
||||
): Promise<HouseholdDocument | null> {
|
||||
const doc = await HouseholdModel.findByIdAndUpdate(
|
||||
id,
|
||||
{ $push: { members: { userId, role, joinedAt: new Date() } } },
|
||||
{ new: true, lean: true, session },
|
||||
).exec();
|
||||
return doc as unknown as HouseholdDocument | null;
|
||||
}
|
||||
|
||||
public async updateInviteCode(id: string, inviteCode: string) {
|
||||
return HouseholdModel.findByIdAndUpdate(
|
||||
public async updateInviteCode(id: string, inviteCode: string): Promise<HouseholdDocument | null> {
|
||||
const doc = await HouseholdModel.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: { inviteCode } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as HouseholdDocument | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,53 +7,76 @@ import {
|
|||
UpdateHouseholdSchema,
|
||||
JoinHouseholdSchema,
|
||||
HouseholdResponseSchema,
|
||||
type CreateHouseholdInput,
|
||||
type UpdateHouseholdInput,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type AnyHouseholdDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
members: ReadonlyArray<{
|
||||
userId: string;
|
||||
role: string;
|
||||
joinedAt: string | { toISOString: () => string };
|
||||
}>;
|
||||
inviteCode: string;
|
||||
settings?: { timezone?: string; currency?: string; language?: string } | null;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
}
|
||||
|
||||
function toIso(v: string | { toISOString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toISOString();
|
||||
}
|
||||
|
||||
function toHouseholdResponse(doc: AnyHouseholdDoc): z.infer<typeof HouseholdResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
name: doc.name,
|
||||
ownerUserId: doc.ownerUserId,
|
||||
members: doc.members.map((m) => ({
|
||||
userId: m.userId,
|
||||
role: m.role,
|
||||
joinedAt: toIso(m.joinedAt),
|
||||
})),
|
||||
inviteCode: doc.inviteCode,
|
||||
settings: {
|
||||
timezone: doc.settings?.timezone ?? 'UTC',
|
||||
currency: doc.settings?.currency ?? 'USD',
|
||||
language: doc.settings?.language ?? 'en',
|
||||
},
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
};
|
||||
}
|
||||
import { HouseholdsRepository } from './households.repository.js';
|
||||
import { HouseholdsService } from './households.service.js';
|
||||
import type { HouseholdDocument } from '../../schemas/household.schema.js';
|
||||
|
||||
interface SerializedHouseholdResponse {
|
||||
_id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
members: Array<{
|
||||
userId: string;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
}>;
|
||||
inviteCode: string;
|
||||
settings: {
|
||||
timezone: string;
|
||||
currency: string;
|
||||
language: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toHouseholdResponse(doc: HouseholdDocument): SerializedHouseholdResponse {
|
||||
const docAny = doc as unknown as {
|
||||
settings?: { timezone?: string; currency?: string; language?: string } | null;
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
};
|
||||
|
||||
const members = doc.members.map((m) => {
|
||||
const joinedAtStr =
|
||||
typeof m.joinedAt === 'object' && typeof m.joinedAt?.toISOString === 'function'
|
||||
? m.joinedAt.toISOString()
|
||||
: String(m.joinedAt || '');
|
||||
return {
|
||||
userId: m.userId,
|
||||
role: m.role,
|
||||
joinedAt: joinedAtStr,
|
||||
};
|
||||
});
|
||||
|
||||
const createdAtStr =
|
||||
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
|
||||
? docAny.createdAt.toISOString()
|
||||
: String(docAny.createdAt || '');
|
||||
|
||||
const updatedAtStr =
|
||||
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
|
||||
? docAny.updatedAt.toISOString()
|
||||
: String(docAny.updatedAt || '');
|
||||
|
||||
return {
|
||||
_id: doc._id.toString(),
|
||||
name: doc.name,
|
||||
ownerUserId: doc.ownerUserId,
|
||||
members,
|
||||
inviteCode: doc.inviteCode,
|
||||
settings: {
|
||||
timezone: docAny.settings?.timezone ?? 'UTC',
|
||||
currency: docAny.settings?.currency ?? 'USD',
|
||||
language: docAny.settings?.language ?? 'en',
|
||||
},
|
||||
createdAt: createdAtStr,
|
||||
updatedAt: updatedAtStr,
|
||||
};
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
interface Cradle {
|
||||
|
|
@ -83,7 +106,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const household = await service.create(request.body, request.user.keycloakId);
|
||||
const body = request.body as CreateHouseholdInput;
|
||||
const household = await service.create(body, request.user.keycloakId);
|
||||
return reply.status(201).send(toHouseholdResponse(household));
|
||||
},
|
||||
});
|
||||
|
|
@ -98,7 +122,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const household = await service.getById(request.params.householdId);
|
||||
const params = request.params as { householdId: string };
|
||||
const household = await service.getById(params.householdId);
|
||||
return reply.send(toHouseholdResponse(household));
|
||||
},
|
||||
});
|
||||
|
|
@ -114,11 +139,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const household = await service.update(
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as UpdateHouseholdInput;
|
||||
const household = await service.update(params.householdId, body, request.user.keycloakId);
|
||||
return reply.send(toHouseholdResponse(household));
|
||||
},
|
||||
});
|
||||
|
|
@ -133,8 +156,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const params = request.params as { householdId: string };
|
||||
const household = await service.generateInviteCode(
|
||||
request.params.householdId,
|
||||
params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(toHouseholdResponse(household));
|
||||
|
|
@ -152,7 +176,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('householdsService');
|
||||
const household = await service.join(request.body.inviteCode, request.user.keycloakId);
|
||||
const body = request.body as { inviteCode: string };
|
||||
const household = await service.join(body.inviteCode, request.user.keycloakId);
|
||||
return reply.send(toHouseholdResponse(household));
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import mongoose from 'mongoose';
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { HouseholdsRepository } from './households.repository.js';
|
||||
import type { UsersRepository } from '../users/users.repository.js';
|
||||
import type { HouseholdDocument } from '../../schemas/household.schema.js';
|
||||
import type { CreateHouseholdInput, UpdateHouseholdInput } from '@meshitrack/shared';
|
||||
import { HouseholdRole } from '@meshitrack/shared';
|
||||
import { NotFoundError, ForbiddenError, ConflictError } from '../../common/errors.js';
|
||||
|
|
@ -20,7 +21,10 @@ export class HouseholdsService {
|
|||
this.usersRepository = usersRepository;
|
||||
}
|
||||
|
||||
public async create(data: CreateHouseholdInput, ownerKeycloakId: string) {
|
||||
public async create(
|
||||
data: CreateHouseholdInput,
|
||||
ownerKeycloakId: string,
|
||||
): Promise<HouseholdDocument> {
|
||||
const inviteCode = uuidv4().slice(0, 8).toUpperCase();
|
||||
const session = await mongoose.startSession();
|
||||
try {
|
||||
|
|
@ -52,11 +56,11 @@ export class HouseholdsService {
|
|||
await session.abortTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
session.endSession();
|
||||
await session.endSession();
|
||||
}
|
||||
}
|
||||
|
||||
public async getById(id: string) {
|
||||
public async getById(id: string): Promise<HouseholdDocument> {
|
||||
const household = await this.householdsRepository.findById(id);
|
||||
if (!household) {
|
||||
throw new NotFoundError('Household not found');
|
||||
|
|
@ -64,7 +68,11 @@ export class HouseholdsService {
|
|||
return household;
|
||||
}
|
||||
|
||||
public async update(id: string, data: UpdateHouseholdInput, requestingUserId: string) {
|
||||
public async update(
|
||||
id: string,
|
||||
data: UpdateHouseholdInput,
|
||||
requestingUserId: string,
|
||||
): Promise<HouseholdDocument> {
|
||||
const household = await this.getById(id);
|
||||
const member = household.members.find((m) => m.userId === requestingUserId);
|
||||
if (!member || (member.role !== HouseholdRole.OWNER && member.role !== HouseholdRole.ADMIN)) {
|
||||
|
|
@ -75,7 +83,10 @@ export class HouseholdsService {
|
|||
return updated;
|
||||
}
|
||||
|
||||
public async generateInviteCode(id: string, requestingUserId: string) {
|
||||
public async generateInviteCode(
|
||||
id: string,
|
||||
requestingUserId: string,
|
||||
): Promise<HouseholdDocument> {
|
||||
const household = await this.getById(id);
|
||||
const member = household.members.find((m) => m.userId === requestingUserId);
|
||||
if (!member || (member.role !== HouseholdRole.OWNER && member.role !== HouseholdRole.ADMIN)) {
|
||||
|
|
@ -87,7 +98,7 @@ export class HouseholdsService {
|
|||
return updated;
|
||||
}
|
||||
|
||||
public async join(inviteCode: string, userId: string) {
|
||||
public async join(inviteCode: string, userId: string): Promise<HouseholdDocument> {
|
||||
const household = await this.householdsRepository.findByInviteCode(inviteCode);
|
||||
if (!household) {
|
||||
throw new NotFoundError('Invalid invite code');
|
||||
|
|
@ -129,7 +140,7 @@ export class HouseholdsService {
|
|||
await session.abortTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
session.endSession();
|
||||
await session.endSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { NutritionInfo } from '@meshitrack/shared';
|
||||
|
||||
export interface NutritionExtractionResult {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ import type {
|
|||
} from './llm-provider.interface.js';
|
||||
|
||||
export class NoOpLlmProvider implements ILlmProvider {
|
||||
private warn(method: string): void {
|
||||
console.warn(`[NoOpLlmProvider] ${method} called but no LLM provider is configured.`);
|
||||
}
|
||||
|
||||
public async extractNutrition(_input: {
|
||||
text?: string;
|
||||
image?: Buffer;
|
||||
|
|
@ -45,4 +41,8 @@ export class NoOpLlmProvider implements ILlmProvider {
|
|||
this.warn('parseNaturalLanguage');
|
||||
return null;
|
||||
}
|
||||
|
||||
private warn(method: string): void {
|
||||
console.warn(`[NoOpLlmProvider] ${method} called but no LLM provider is configured.`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { MedicinePriceModel } from '../../schemas/medicine-price.schema.js';
|
||||
import type { MedicinePriceDocument } from '../../schemas/medicine-price.schema.js';
|
||||
import type {
|
||||
MedicinePriceHistoryQueryInput,
|
||||
MedicinePriceAnalyticsQueryInput,
|
||||
|
|
@ -24,17 +25,20 @@ export interface CreateMedicinePriceData {
|
|||
}
|
||||
|
||||
export class MedicinePricesRepository {
|
||||
public async create(data: CreateMedicinePriceData) {
|
||||
public async create(data: CreateMedicinePriceData): Promise<MedicinePriceDocument> {
|
||||
const record = new MedicinePriceModel(data);
|
||||
const saved = await record.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as MedicinePriceDocument;
|
||||
}
|
||||
|
||||
public async findByMedicine(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
query: MedicinePriceHistoryQueryInput,
|
||||
) {
|
||||
): Promise<{
|
||||
data: MedicinePriceDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
const filter: Record<string, unknown> = { householdId, medicineId };
|
||||
|
||||
if (query.storeId) filter['storeId'] = query.storeId;
|
||||
|
|
@ -63,12 +67,37 @@ export class MedicinePricesRepository {
|
|||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
return {
|
||||
data: data as unknown as MedicinePriceDocument[],
|
||||
pagination: { cursor: hasMore ? cursor : null, hasMore },
|
||||
};
|
||||
}
|
||||
|
||||
public async compareStores(householdId: string, medicineId: string) {
|
||||
// Get the most recent price per store for this medicine
|
||||
const results = await MedicinePriceModel.aggregate([
|
||||
public async compareStores(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
latestPrice: number;
|
||||
latestPricePerUnit: number;
|
||||
currency: string;
|
||||
date: Date;
|
||||
isInsurancePrice: boolean;
|
||||
}>
|
||||
> {
|
||||
interface CompareStoresGroup {
|
||||
_id: string;
|
||||
storeName: string;
|
||||
latestPrice: number;
|
||||
latestPricePerUnit: number;
|
||||
currency: string;
|
||||
date: Date;
|
||||
isInsurancePrice: boolean;
|
||||
}
|
||||
|
||||
const resultsRaw = await MedicinePriceModel.aggregate([
|
||||
{ $match: { householdId, medicineId } },
|
||||
{ $sort: { storeId: 1, date: -1 } },
|
||||
{
|
||||
|
|
@ -85,28 +114,88 @@ export class MedicinePricesRepository {
|
|||
{ $sort: { latestPricePerUnit: 1 } },
|
||||
]).exec();
|
||||
|
||||
const results = resultsRaw as unknown as CompareStoresGroup[];
|
||||
|
||||
return results.map((r) => ({
|
||||
storeId: r._id as string,
|
||||
storeName: r.storeName as string,
|
||||
latestPrice: r.latestPrice as number,
|
||||
latestPricePerUnit: r.latestPricePerUnit as number,
|
||||
currency: r.currency as string,
|
||||
date: r.date as Date,
|
||||
isInsurancePrice: r.isInsurancePrice as boolean,
|
||||
storeId: r._id,
|
||||
storeName: r.storeName,
|
||||
latestPrice: r.latestPrice,
|
||||
latestPricePerUnit: r.latestPricePerUnit,
|
||||
currency: r.currency,
|
||||
date: r.date,
|
||||
isInsurancePrice: r.isInsurancePrice,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getLatestForMedicine(householdId: string, medicineId: string, storeId?: string) {
|
||||
public async getLatestForMedicine(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
storeId?: string,
|
||||
): Promise<MedicinePriceDocument | null> {
|
||||
const filter: Record<string, unknown> = { householdId, medicineId };
|
||||
if (storeId) filter['storeId'] = storeId;
|
||||
return MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
|
||||
const doc = await MedicinePriceModel.findOne(filter).sort({ date: -1 }).lean().exec();
|
||||
return doc as unknown as MedicinePriceDocument | null;
|
||||
}
|
||||
|
||||
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
|
||||
public async getAnalytics(
|
||||
householdId: string,
|
||||
query: MedicinePriceAnalyticsQueryInput,
|
||||
): Promise<{
|
||||
spendingOverTime: Array<{ period: string; total: number }>;
|
||||
topBySpending: Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
totalSpent: number;
|
||||
avgPricePerUnit: number;
|
||||
}>;
|
||||
spendingByStore: Array<{
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
totalSpent: number;
|
||||
purchaseCount: number;
|
||||
}>;
|
||||
priceAlerts: Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
storeName: string;
|
||||
previousPrice: number;
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
}>;
|
||||
}> {
|
||||
const dateFormat =
|
||||
query.period === 'month' ? '%Y-%m' : query.period === 'quarter' ? '%Y-Q%q' : '%Y';
|
||||
|
||||
const [spendingOverTime, topBySpending, spendingByStore] = await Promise.all([
|
||||
interface SpendingOverTimeGroup {
|
||||
period: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface TopBySpendingGroup {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
totalSpent: number;
|
||||
avgPricePerUnit: number;
|
||||
}
|
||||
|
||||
interface SpendingByStoreGroup {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
totalSpent: number;
|
||||
purchaseCount: number;
|
||||
}
|
||||
|
||||
interface PriceAlertGroup {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
storeName: string;
|
||||
previousPrice: number;
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
}
|
||||
|
||||
const [spendingOverTimeRaw, topBySpendingRaw, spendingByStoreRaw] = await Promise.all([
|
||||
MedicinePriceModel.aggregate([
|
||||
{ $match: { householdId } },
|
||||
{
|
||||
|
|
@ -158,7 +247,7 @@ export class MedicinePricesRepository {
|
|||
]);
|
||||
|
||||
// Price alerts: medicines where the most recent price is >10% higher than the previous
|
||||
const priceAlerts = await MedicinePriceModel.aggregate([
|
||||
const priceAlertsRaw = await MedicinePriceModel.aggregate([
|
||||
{ $match: { householdId } },
|
||||
{ $sort: { medicineId: 1, storeId: 1, date: -1 } },
|
||||
{
|
||||
|
|
@ -201,27 +290,10 @@ export class MedicinePricesRepository {
|
|||
]).exec();
|
||||
|
||||
return {
|
||||
spendingOverTime: spendingOverTime as { period: string; total: number }[],
|
||||
topBySpending: topBySpending as {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
totalSpent: number;
|
||||
avgPricePerUnit: number;
|
||||
}[],
|
||||
spendingByStore: spendingByStore as {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
totalSpent: number;
|
||||
purchaseCount: number;
|
||||
}[],
|
||||
priceAlerts: priceAlerts as {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
storeName: string;
|
||||
previousPrice: number;
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
}[],
|
||||
spendingOverTime: spendingOverTimeRaw as unknown as SpendingOverTimeGroup[],
|
||||
topBySpending: topBySpendingRaw as unknown as TopBySpendingGroup[],
|
||||
spendingByStore: spendingByStoreRaw as unknown as SpendingByStoreGroup[],
|
||||
priceAlerts: priceAlertsRaw as unknown as PriceAlertGroup[],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,16 @@ import {
|
|||
MedicinePriceHistoryResponseSchema,
|
||||
StoreComparisonResponseSchema,
|
||||
MedicineSpendingAnalyticsResponseSchema,
|
||||
type CreateMedicinePriceRecordInput,
|
||||
type MedicinePriceHistoryQueryInput,
|
||||
type MedicinePriceAnalyticsQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { MedicinePricesRepository } from './medicine-prices.repository.js';
|
||||
import { MedicinePricesService } from './medicine-prices.service.js';
|
||||
import type { MedicinePriceDocument } from '../../schemas/medicine-price.schema.js';
|
||||
|
||||
type AnyPriceDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedMedicinePriceRecordResponse {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
medicineProductId: string;
|
||||
medicineProductBrand: string;
|
||||
|
|
@ -28,22 +32,32 @@ type AnyPriceDoc = {
|
|||
quantity: number;
|
||||
unit: string;
|
||||
pricePerUnit: number;
|
||||
date: Date | string | { toISOString: () => string };
|
||||
date: string;
|
||||
isInsurancePrice: boolean;
|
||||
notes?: string;
|
||||
createdBy: string;
|
||||
createdAt: Date | string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toIso(v: Date | string | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
return v.toISOString();
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function toPriceRecordResponse(rawDoc: unknown) {
|
||||
const doc = rawDoc as AnyPriceDoc;
|
||||
return {
|
||||
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||
function toPriceRecordResponse(doc: MedicinePriceDocument): SerializedMedicinePriceRecordResponse {
|
||||
const docAny = doc as unknown as {
|
||||
notes?: string | null;
|
||||
date?: { toISOString?: () => string } | string | Date;
|
||||
createdAt?: { toISOString?: () => string } | string | Date;
|
||||
};
|
||||
|
||||
const getIsoStr = (
|
||||
d: { toISOString?: () => string } | string | Date | undefined | null,
|
||||
): string => {
|
||||
if (!d) return '';
|
||||
if (typeof d === 'string') return d;
|
||||
if (d instanceof Date) return d.toISOString();
|
||||
if (typeof d.toISOString === 'function') return d.toISOString();
|
||||
return String(d);
|
||||
};
|
||||
|
||||
const response: SerializedMedicinePriceRecordResponse = {
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
medicineProductId: doc.medicineProductId,
|
||||
medicineProductBrand: doc.medicineProductBrand,
|
||||
|
|
@ -56,12 +70,15 @@ function toPriceRecordResponse(rawDoc: unknown) {
|
|||
quantity: doc.quantity,
|
||||
unit: doc.unit,
|
||||
pricePerUnit: doc.pricePerUnit,
|
||||
date: toIso(doc.date),
|
||||
date: getIsoStr(docAny.date),
|
||||
isInsurancePrice: doc.isInsurancePrice,
|
||||
...(doc.notes != null ? { notes: doc.notes } : {}),
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
createdAt: getIsoStr(docAny.createdAt),
|
||||
};
|
||||
|
||||
if (docAny.notes) response.notes = docAny.notes;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
|
|
@ -91,11 +108,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||
const record = await service.recordPrice(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as CreateMedicinePriceRecordInput;
|
||||
const record = await service.recordPrice(body, params.householdId, request.user.keycloakId);
|
||||
return reply.status(201).send(toPriceRecordResponse(record));
|
||||
},
|
||||
});
|
||||
|
|
@ -110,11 +125,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||
const result = await service.getPriceHistory(
|
||||
request.params.householdId,
|
||||
request.params.medicineId,
|
||||
request.query,
|
||||
);
|
||||
const params = request.params as { householdId: string; medicineId: string };
|
||||
const query = request.query as MedicinePriceHistoryQueryInput;
|
||||
const result = await service.getPriceHistory(params.householdId, params.medicineId, query);
|
||||
return reply.send({
|
||||
data: result.data.map(toPriceRecordResponse),
|
||||
pagination: result.pagination,
|
||||
|
|
@ -131,14 +144,12 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||
const results = await service.compareStores(
|
||||
request.params.householdId,
|
||||
request.params.medicineId,
|
||||
);
|
||||
const params = request.params as { householdId: string; medicineId: string };
|
||||
const results = await service.compareStores(params.householdId, params.medicineId);
|
||||
return reply.send({
|
||||
data: results.map((r) => ({
|
||||
...r,
|
||||
date: toIso(r.date),
|
||||
date: r.date.toISOString(),
|
||||
})),
|
||||
});
|
||||
},
|
||||
|
|
@ -154,7 +165,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinePricesService');
|
||||
const analytics = await service.getAnalytics(request.params.householdId, request.query);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as MedicinePriceAnalyticsQueryInput;
|
||||
const analytics = await service.getAnalytics(params.householdId, query);
|
||||
return reply.send(analytics);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { MedicinePricesRepository } from './medicine-prices.repository.js';
|
||||
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||
import type { StoresRepository } from '../stores/stores.repository.js';
|
||||
import type { MedicinePriceDocument } from '../../schemas/medicine-price.schema.js';
|
||||
import type {
|
||||
CreateMedicinePriceRecordInput,
|
||||
MedicinePriceHistoryQueryInput,
|
||||
|
|
@ -33,7 +34,7 @@ export class MedicinePricesService {
|
|||
data: CreateMedicinePriceRecordInput,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
) {
|
||||
): Promise<MedicinePriceDocument> {
|
||||
const product = await this.medicineProductsRepository.findById(
|
||||
data.medicineProductId,
|
||||
householdId,
|
||||
|
|
@ -70,11 +71,27 @@ export class MedicinePricesService {
|
|||
householdId: string,
|
||||
medicineId: string,
|
||||
query: MedicinePriceHistoryQueryInput,
|
||||
) {
|
||||
): Promise<{
|
||||
data: MedicinePriceDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
return this.medicinePricesRepository.findByMedicine(householdId, medicineId, query);
|
||||
}
|
||||
|
||||
public async compareStores(householdId: string, medicineId: string) {
|
||||
public async compareStores(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
latestPrice: number;
|
||||
latestPricePerUnit: number;
|
||||
currency: string;
|
||||
date: Date;
|
||||
isInsurancePrice: boolean;
|
||||
}>
|
||||
> {
|
||||
return this.medicinePricesRepository.compareStores(householdId, medicineId);
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +108,32 @@ export class MedicinePricesService {
|
|||
return record ? (record.pricePerUnit as number) : null;
|
||||
}
|
||||
|
||||
public async getAnalytics(householdId: string, query: MedicinePriceAnalyticsQueryInput) {
|
||||
public async getAnalytics(
|
||||
householdId: string,
|
||||
query: MedicinePriceAnalyticsQueryInput,
|
||||
): Promise<{
|
||||
spendingOverTime: Array<{ period: string; total: number }>;
|
||||
topBySpending: Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
totalSpent: number;
|
||||
avgPricePerUnit: number;
|
||||
}>;
|
||||
spendingByStore: Array<{
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
totalSpent: number;
|
||||
purchaseCount: number;
|
||||
}>;
|
||||
priceAlerts: Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
storeName: string;
|
||||
previousPrice: number;
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
}>;
|
||||
}> {
|
||||
return this.medicinePricesRepository.getAnalytics(householdId, query);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { MedicineProductModel } from '../../schemas/medicine-product.schema.js';
|
||||
import type { MedicineProductDocument } from '../../schemas/medicine-product.schema.js';
|
||||
import type { CreateMedicineProductInput, UpdateMedicineProductInput } from '@meshitrack/shared';
|
||||
|
||||
interface FindByMedicineQuery {
|
||||
|
|
@ -7,7 +8,14 @@ interface FindByMedicineQuery {
|
|||
}
|
||||
|
||||
export class MedicineProductsRepository {
|
||||
public async findByMedicine(householdId: string, medicineId: string, query: FindByMedicineQuery) {
|
||||
public async findByMedicine(
|
||||
householdId: string,
|
||||
medicineId: string,
|
||||
query: FindByMedicineQuery,
|
||||
): Promise<{
|
||||
data: MedicineProductDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
const filter: Record<string, unknown> = { householdId, medicineId, isDeleted: false };
|
||||
|
||||
if (query.cursor) {
|
||||
|
|
@ -27,11 +35,17 @@ export class MedicineProductsRepository {
|
|||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
return {
|
||||
data: data as unknown as MedicineProductDocument[],
|
||||
pagination: { cursor: hasMore ? cursor : null, hasMore },
|
||||
};
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return MedicineProductModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
|
||||
public async findById(id: string, householdId: string): Promise<MedicineProductDocument | null> {
|
||||
const doc = await MedicineProductModel.findOne({ _id: id, householdId, isDeleted: false })
|
||||
.lean()
|
||||
.exec();
|
||||
return doc as unknown as MedicineProductDocument | null;
|
||||
}
|
||||
|
||||
public async create(
|
||||
|
|
@ -40,7 +54,7 @@ export class MedicineProductsRepository {
|
|||
medicineId: string,
|
||||
medicineName: string,
|
||||
createdBy: string,
|
||||
) {
|
||||
): Promise<MedicineProductDocument> {
|
||||
const product = new MedicineProductModel({
|
||||
...data,
|
||||
householdId,
|
||||
|
|
@ -49,26 +63,35 @@ export class MedicineProductsRepository {
|
|||
createdBy,
|
||||
});
|
||||
const saved = await product.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as MedicineProductDocument;
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateMedicineProductInput) {
|
||||
return MedicineProductModel.findOneAndUpdate(
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateMedicineProductInput,
|
||||
): Promise<MedicineProductDocument | null> {
|
||||
const doc = await MedicineProductModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as MedicineProductDocument | null;
|
||||
}
|
||||
|
||||
public async countByMedicineId(medicineId: string): Promise<number> {
|
||||
return MedicineProductModel.countDocuments({ medicineId, isDeleted: false }).exec();
|
||||
}
|
||||
|
||||
public async softDelete(id: string, householdId: string) {
|
||||
return MedicineProductModel.findOneAndUpdate(
|
||||
public async softDelete(
|
||||
id: string,
|
||||
householdId: string,
|
||||
): Promise<MedicineProductDocument | null> {
|
||||
const doc = await MedicineProductModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: { isDeleted: true } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as MedicineProductDocument | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,56 +7,74 @@ import {
|
|||
UpdateMedicineProductSchema,
|
||||
MedicineProductResponseSchema,
|
||||
MedicineProductListResponseSchema,
|
||||
type CreateMedicineProductInput,
|
||||
type UpdateMedicineProductInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { MedicineProductsRepository } from './medicine-products.repository.js';
|
||||
import { MedicineProductsService } from './medicine-products.service.js';
|
||||
import type { MedicineProductDocument } from '../../schemas/medicine-product.schema.js';
|
||||
|
||||
type AnyProductDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedMedicineProductResponse {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
brand: string;
|
||||
manufacturer?: string | null;
|
||||
manufacturer?: string;
|
||||
packageSize: number;
|
||||
packageUnit: string;
|
||||
concentration?: number | null;
|
||||
concentrationUnit?: string | null;
|
||||
imageUrl?: string | null;
|
||||
notes?: string | null;
|
||||
concentration?: number;
|
||||
concentrationUnit?: string;
|
||||
imageUrl?: string;
|
||||
notes?: string;
|
||||
source: string;
|
||||
createdBy: string;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toIso(v: string | { toISOString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toISOString();
|
||||
}
|
||||
function toProductResponse(doc: MedicineProductDocument): SerializedMedicineProductResponse {
|
||||
const docAny = doc as unknown as {
|
||||
manufacturer?: string | null;
|
||||
concentration?: number | null;
|
||||
concentrationUnit?: string | null;
|
||||
imageUrl?: string | null;
|
||||
notes?: string | null;
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
};
|
||||
|
||||
function toProductResponse(doc: AnyProductDoc): z.infer<typeof MedicineProductResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
const createdAtStr =
|
||||
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
|
||||
? docAny.createdAt.toISOString()
|
||||
: String(docAny.createdAt || '');
|
||||
|
||||
const updatedAtStr =
|
||||
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
|
||||
? docAny.updatedAt.toISOString()
|
||||
: String(docAny.updatedAt || '');
|
||||
|
||||
const response: SerializedMedicineProductResponse = {
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
medicineId: doc.medicineId,
|
||||
medicineName: doc.medicineName,
|
||||
brand: doc.brand,
|
||||
...(doc.manufacturer ? { manufacturer: doc.manufacturer } : {}),
|
||||
packageSize: doc.packageSize,
|
||||
packageUnit: doc.packageUnit,
|
||||
...(doc.concentration ? { concentration: doc.concentration } : {}),
|
||||
...(doc.concentrationUnit ? { concentrationUnit: doc.concentrationUnit } : {}),
|
||||
...(doc.imageUrl ? { imageUrl: doc.imageUrl } : {}),
|
||||
...(doc.notes ? { notes: doc.notes } : {}),
|
||||
source: doc.source,
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
createdAt: createdAtStr,
|
||||
updatedAt: updatedAtStr,
|
||||
};
|
||||
|
||||
if (docAny.manufacturer) response.manufacturer = docAny.manufacturer;
|
||||
if (docAny.concentration != null) response.concentration = docAny.concentration;
|
||||
if (docAny.concentrationUnit) response.concentrationUnit = docAny.concentrationUnit;
|
||||
if (docAny.imageUrl) response.imageUrl = docAny.imageUrl;
|
||||
if (docAny.notes) response.notes = docAny.notes;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
|
|
@ -93,11 +111,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicineProductsService');
|
||||
const result = await service.listByMedicine(
|
||||
request.params.householdId,
|
||||
request.params.medicineId,
|
||||
request.query,
|
||||
);
|
||||
const params = request.params as { householdId: string; medicineId: string };
|
||||
const query = request.query as { cursor?: string; limit: number };
|
||||
const result = await service.listByMedicine(params.householdId, params.medicineId, query);
|
||||
return reply.send({
|
||||
data: result.data.map(toProductResponse),
|
||||
pagination: result.pagination,
|
||||
|
|
@ -115,7 +131,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicineProductsService');
|
||||
const product = await service.getById(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const product = await service.getById(params.id, params.householdId);
|
||||
return reply.send(toProductResponse(product));
|
||||
},
|
||||
});
|
||||
|
|
@ -131,10 +148,12 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicineProductsService');
|
||||
const params = request.params as { householdId: string; medicineId: string };
|
||||
const body = request.body as CreateMedicineProductInput;
|
||||
const product = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.params.medicineId,
|
||||
body,
|
||||
params.householdId,
|
||||
params.medicineId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.status(201).send(toProductResponse(product));
|
||||
|
|
@ -152,11 +171,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicineProductsService');
|
||||
const product = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as UpdateMedicineProductInput;
|
||||
const product = await service.update(params.id, params.householdId, body);
|
||||
return reply.send(toProductResponse(product));
|
||||
},
|
||||
});
|
||||
|
|
@ -171,7 +188,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicineProductsService');
|
||||
await service.delete(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
await service.delete(params.id, params.householdId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { MedicineProductsRepository } from './medicine-products.repository.js';
|
||||
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
|
||||
import type { MedicineProductDocument } from '../../schemas/medicine-product.schema.js';
|
||||
import type { CreateMedicineProductInput, UpdateMedicineProductInput } from '@meshitrack/shared';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
|
|
@ -21,11 +22,14 @@ export class MedicineProductsService {
|
|||
householdId: string,
|
||||
medicineId: string,
|
||||
query: { cursor?: string; limit: number },
|
||||
) {
|
||||
): Promise<{
|
||||
data: MedicineProductDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
return this.medicineProductsRepository.findByMedicine(householdId, medicineId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
public async getById(id: string, householdId: string): Promise<MedicineProductDocument> {
|
||||
const product = await this.medicineProductsRepository.findById(id, householdId);
|
||||
if (!product) {
|
||||
throw new NotFoundError('Medicine product not found');
|
||||
|
|
@ -38,7 +42,7 @@ export class MedicineProductsService {
|
|||
householdId: string,
|
||||
medicineId: string,
|
||||
createdBy: string,
|
||||
) {
|
||||
): Promise<MedicineProductDocument> {
|
||||
const medicine = await this.medicinesRepository.findById(medicineId, householdId);
|
||||
if (!medicine) {
|
||||
throw new NotFoundError('Medicine not found');
|
||||
|
|
@ -53,14 +57,18 @@ export class MedicineProductsService {
|
|||
);
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateMedicineProductInput) {
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateMedicineProductInput,
|
||||
): Promise<MedicineProductDocument> {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.medicineProductsRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Medicine product not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
public async delete(id: string, householdId: string): Promise<MedicineProductDocument> {
|
||||
await this.getById(id, householdId);
|
||||
const deleted = await this.medicineProductsRepository.softDelete(id, householdId);
|
||||
if (!deleted) throw new NotFoundError('Medicine product not found');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { MedicineModel } from '../../schemas/medicine.schema.js';
|
||||
import type { MedicineDocument } from '../../schemas/medicine.schema.js';
|
||||
import type {
|
||||
CreateMedicineInput,
|
||||
UpdateMedicineInput,
|
||||
|
|
@ -6,7 +7,13 @@ import type {
|
|||
} from '@meshitrack/shared';
|
||||
|
||||
export class MedicinesRepository {
|
||||
public async findByHousehold(householdId: string, query: MedicineQueryInput) {
|
||||
public async findByHousehold(
|
||||
householdId: string,
|
||||
query: MedicineQueryInput,
|
||||
): Promise<{
|
||||
data: MedicineDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
const filter: Record<string, unknown> = { householdId, isDeleted: false };
|
||||
|
||||
if (query.category) filter['category'] = query.category;
|
||||
|
|
@ -30,11 +37,17 @@ export class MedicinesRepository {
|
|||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
return {
|
||||
data: data as unknown as MedicineDocument[],
|
||||
pagination: { cursor: hasMore ? cursor : null, hasMore },
|
||||
};
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return MedicineModel.findOne({ _id: id, householdId, isDeleted: false }).lean().exec();
|
||||
public async findById(id: string, householdId: string): Promise<MedicineDocument | null> {
|
||||
const doc = await MedicineModel.findOne({ _id: id, householdId, isDeleted: false })
|
||||
.lean()
|
||||
.exec();
|
||||
return doc as unknown as MedicineDocument | null;
|
||||
}
|
||||
|
||||
public async findDuplicate(
|
||||
|
|
@ -44,7 +57,7 @@ export class MedicinesRepository {
|
|||
strengthUnit: string,
|
||||
form: string,
|
||||
excludeId?: string,
|
||||
) {
|
||||
): Promise<MedicineDocument | null> {
|
||||
const filter: Record<string, unknown> = {
|
||||
householdId,
|
||||
name,
|
||||
|
|
@ -54,28 +67,39 @@ export class MedicinesRepository {
|
|||
isDeleted: false,
|
||||
};
|
||||
if (excludeId) filter['_id'] = { $ne: excludeId };
|
||||
return MedicineModel.findOne(filter).lean().exec();
|
||||
const doc = await MedicineModel.findOne(filter).lean().exec();
|
||||
return doc as unknown as MedicineDocument | null;
|
||||
}
|
||||
|
||||
public async create(data: CreateMedicineInput, householdId: string, createdBy: string) {
|
||||
public async create(
|
||||
data: CreateMedicineInput,
|
||||
householdId: string,
|
||||
createdBy: string,
|
||||
): Promise<MedicineDocument> {
|
||||
const medicine = new MedicineModel({ ...data, householdId, createdBy });
|
||||
const saved = await medicine.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as MedicineDocument;
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateMedicineInput) {
|
||||
return MedicineModel.findOneAndUpdate(
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateMedicineInput,
|
||||
): Promise<MedicineDocument | null> {
|
||||
const doc = await MedicineModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as MedicineDocument | null;
|
||||
}
|
||||
|
||||
public async softDelete(id: string, householdId: string) {
|
||||
return MedicineModel.findOneAndUpdate(
|
||||
public async softDelete(id: string, householdId: string): Promise<MedicineDocument | null> {
|
||||
const doc = await MedicineModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, isDeleted: false },
|
||||
{ $set: { isDeleted: true } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as MedicineDocument | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,48 +8,65 @@ import {
|
|||
MedicineQuerySchema,
|
||||
MedicineResponseSchema,
|
||||
MedicineListResponseSchema,
|
||||
type CreateMedicineInput,
|
||||
type UpdateMedicineInput,
|
||||
type MedicineQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { MedicinesRepository } from './medicines.repository.js';
|
||||
import { MedicinesService } from './medicines.service.js';
|
||||
import type { MedicineDocument } from '../../schemas/medicine.schema.js';
|
||||
|
||||
type AnyMedicineDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedMedicineResponse {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
form: string;
|
||||
strength: number;
|
||||
strengthUnit: string;
|
||||
category: string;
|
||||
notes?: string | null;
|
||||
notes?: string;
|
||||
tags: string[];
|
||||
createdBy: string;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toIso(v: string | { toISOString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toISOString();
|
||||
}
|
||||
function toMedicineResponse(doc: MedicineDocument): SerializedMedicineResponse {
|
||||
const docAny = doc as unknown as {
|
||||
notes?: string | null;
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
};
|
||||
|
||||
function toMedicineResponse(doc: AnyMedicineDoc): z.infer<typeof MedicineResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
const createdAtStr =
|
||||
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
|
||||
? docAny.createdAt.toISOString()
|
||||
: String(docAny.createdAt || '');
|
||||
|
||||
const updatedAtStr =
|
||||
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
|
||||
? docAny.updatedAt.toISOString()
|
||||
: String(docAny.updatedAt || '');
|
||||
|
||||
const result: SerializedMedicineResponse = {
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
name: doc.name,
|
||||
form: doc.form,
|
||||
strength: doc.strength,
|
||||
strengthUnit: doc.strengthUnit,
|
||||
category: doc.category,
|
||||
...(doc.notes ? { notes: doc.notes } : {}),
|
||||
tags: doc.tags,
|
||||
tags: doc.tags || [],
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
createdAt: createdAtStr,
|
||||
updatedAt: updatedAtStr,
|
||||
};
|
||||
|
||||
if (docAny.notes) {
|
||||
result.notes = docAny.notes;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
|
|
@ -80,7 +97,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinesService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as MedicineQueryInput;
|
||||
const result = await service.list(params.householdId, query);
|
||||
return reply.send({
|
||||
data: result.data.map(toMedicineResponse),
|
||||
pagination: result.pagination,
|
||||
|
|
@ -98,7 +117,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinesService');
|
||||
const medicine = await service.getById(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const medicine = await service.getById(params.id, params.householdId);
|
||||
return reply.send(toMedicineResponse(medicine));
|
||||
},
|
||||
});
|
||||
|
|
@ -114,11 +134,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinesService');
|
||||
const medicine = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as CreateMedicineInput;
|
||||
const medicine = await service.create(body, params.householdId, request.user.keycloakId);
|
||||
return reply.status(201).send(toMedicineResponse(medicine));
|
||||
},
|
||||
});
|
||||
|
|
@ -134,11 +152,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinesService');
|
||||
const medicine = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as UpdateMedicineInput;
|
||||
const medicine = await service.update(params.id, params.householdId, body);
|
||||
return reply.send(toMedicineResponse(medicine));
|
||||
},
|
||||
});
|
||||
|
|
@ -153,7 +169,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('medicinesService');
|
||||
await service.delete(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
await service.delete(params.id, params.householdId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { MedicinesRepository } from './medicines.repository.js';
|
||||
import type { MedicineProductsRepository } from '../medicine-products/medicine-products.repository.js';
|
||||
import type { MedicineDocument } from '../../schemas/medicine.schema.js';
|
||||
import type {
|
||||
CreateMedicineInput,
|
||||
UpdateMedicineInput,
|
||||
|
|
@ -21,11 +22,17 @@ export class MedicinesService {
|
|||
this.medicineProductsRepository = medicineProductsRepository;
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: MedicineQueryInput) {
|
||||
public async list(
|
||||
householdId: string,
|
||||
query: MedicineQueryInput,
|
||||
): Promise<{
|
||||
data: MedicineDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
return this.medicinesRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
public async getById(id: string, householdId: string): Promise<MedicineDocument> {
|
||||
const medicine = await this.medicinesRepository.findById(id, householdId);
|
||||
if (!medicine) {
|
||||
throw new NotFoundError('Medicine not found');
|
||||
|
|
@ -33,7 +40,11 @@ export class MedicinesService {
|
|||
return medicine;
|
||||
}
|
||||
|
||||
public async create(data: CreateMedicineInput, householdId: string, createdBy: string) {
|
||||
public async create(
|
||||
data: CreateMedicineInput,
|
||||
householdId: string,
|
||||
createdBy: string,
|
||||
): Promise<MedicineDocument> {
|
||||
const existing = await this.medicinesRepository.findDuplicate(
|
||||
householdId,
|
||||
data.name,
|
||||
|
|
@ -47,7 +58,11 @@ export class MedicinesService {
|
|||
return this.medicinesRepository.create(data, householdId, createdBy);
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateMedicineInput) {
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateMedicineInput,
|
||||
): Promise<MedicineDocument> {
|
||||
await this.getById(id, householdId);
|
||||
|
||||
if (data.name || data.strength || data.strengthUnit || data.form) {
|
||||
|
|
@ -75,7 +90,7 @@ export class MedicinesService {
|
|||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
public async delete(id: string, householdId: string): Promise<MedicineDocument> {
|
||||
await this.getById(id, householdId);
|
||||
|
||||
const productCount = await this.medicineProductsRepository.countByMedicineId(id);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,63 @@
|
|||
import { NutritionTargetModel } from '../../schemas/nutrition-target.schema.js';
|
||||
import type { NutritionTargetDocument } from '../../schemas/nutrition-target.schema.js';
|
||||
import type { UpdateWriteOpResult } from 'mongoose';
|
||||
|
||||
export interface CreateNutritionTargetData {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
dailyCalories: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
fiberG?: number;
|
||||
sodiumMg?: number;
|
||||
sugarG?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateNutritionTargetData {
|
||||
dailyCalories?: number;
|
||||
proteinG?: number;
|
||||
carbsG?: number;
|
||||
fatG?: number;
|
||||
fiberG?: number;
|
||||
sodiumMg?: number;
|
||||
sugarG?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class NutritionTargetRepository {
|
||||
public async findByUser(userId: string, householdId: string) {
|
||||
return NutritionTargetModel.findOne({ userId, householdId, isActive: true }).lean().exec();
|
||||
public async findByUser(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<NutritionTargetDocument | null> {
|
||||
const doc = await NutritionTargetModel.findOne({ userId, householdId, isActive: true })
|
||||
.lean()
|
||||
.exec();
|
||||
return doc as unknown as NutritionTargetDocument | null;
|
||||
}
|
||||
|
||||
public async findAllByUser(userId: string, householdId: string) {
|
||||
return NutritionTargetModel.find({ userId, householdId }).sort({ createdAt: -1 }).lean().exec();
|
||||
public async findAllByUser(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<NutritionTargetDocument[]> {
|
||||
const docs = await NutritionTargetModel.find({ userId, householdId })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return docs as unknown as NutritionTargetDocument[];
|
||||
}
|
||||
|
||||
public async create(data: Record<string, unknown>) {
|
||||
public async create(data: CreateNutritionTargetData): Promise<NutritionTargetDocument> {
|
||||
const doc = new NutritionTargetModel(data);
|
||||
const saved = await doc.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as unknown as NutritionTargetDocument;
|
||||
}
|
||||
|
||||
public async deactivateAllForUser(userId: string, householdId: string) {
|
||||
public async deactivateAllForUser(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<UpdateWriteOpResult> {
|
||||
return NutritionTargetModel.updateMany(
|
||||
{ userId, householdId, isActive: true },
|
||||
{ $set: { isActive: false } },
|
||||
|
|
@ -26,12 +68,13 @@ export class NutritionTargetRepository {
|
|||
id: string,
|
||||
userId: string,
|
||||
householdId: string,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
return NutritionTargetModel.findOneAndUpdate(
|
||||
data: UpdateNutritionTargetData,
|
||||
): Promise<NutritionTargetDocument | null> {
|
||||
const doc = await NutritionTargetModel.findOneAndUpdate(
|
||||
{ _id: id, userId, householdId },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as NutritionTargetDocument | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,52 +2,65 @@ import fp from 'fastify-plugin';
|
|||
import { asClass, Lifetime } from 'awilix';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod/v4';
|
||||
import { NutritionTargetSchema, NutritionTargetResponseSchema } from '@meshitrack/shared';
|
||||
import {
|
||||
NutritionTargetSchema,
|
||||
NutritionTargetResponseSchema,
|
||||
type SetNutritionTargetInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { NutritionTargetRepository } from './nutrition-target.repository.js';
|
||||
import { NutritionTargetService } from './nutrition-target.service.js';
|
||||
import { NutritionTargetService, type PresetType } from './nutrition-target.service.js';
|
||||
import type { NutritionTargetDocument } from '../../schemas/nutrition-target.schema.js';
|
||||
|
||||
type AnyTargetDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedNutritionTarget {
|
||||
_id: string;
|
||||
userId: string;
|
||||
householdId: string;
|
||||
dailyCalories: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
fiberG?: number | null;
|
||||
sugarG?: number | null;
|
||||
sodiumMg?: number | null;
|
||||
fiberG?: number;
|
||||
sugarG?: number;
|
||||
sodiumMg?: number;
|
||||
isActive: boolean;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toIso(v: string | Date): string {
|
||||
return typeof v === 'string' ? v : v.toISOString();
|
||||
}
|
||||
function toNutritionTargetResponse(doc: NutritionTargetDocument): SerializedNutritionTarget {
|
||||
const docAny = doc as unknown as {
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
fiberG?: number | null;
|
||||
sugarG?: number | null;
|
||||
sodiumMg?: number | null;
|
||||
};
|
||||
|
||||
function toNutritionTargetResponse(
|
||||
doc: AnyTargetDoc,
|
||||
): z.infer<typeof NutritionTargetResponseSchema> {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
const getIsoStr = (d: { toISOString?: () => string } | string | undefined | null): string => {
|
||||
if (!d) return '';
|
||||
if (typeof d === 'string') return d;
|
||||
if (typeof d.toISOString === 'function') return d.toISOString();
|
||||
return String(d);
|
||||
};
|
||||
|
||||
const res: SerializedNutritionTarget = {
|
||||
_id: doc._id.toString(),
|
||||
userId: doc.userId,
|
||||
householdId: doc.householdId,
|
||||
dailyCalories: doc.dailyCalories,
|
||||
proteinG: doc.proteinG,
|
||||
carbsG: doc.carbsG,
|
||||
fatG: doc.fatG,
|
||||
...(doc.fiberG != null ? { fiberG: doc.fiberG } : {}),
|
||||
...(doc.sugarG != null ? { sugarG: doc.sugarG } : {}),
|
||||
...(doc.sodiumMg != null ? { sodiumMg: doc.sodiumMg } : {}),
|
||||
isActive: doc.isActive,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
createdAt: getIsoStr(docAny.createdAt),
|
||||
updatedAt: getIsoStr(docAny.updatedAt),
|
||||
};
|
||||
|
||||
if (docAny.fiberG != null) res.fiberG = docAny.fiberG;
|
||||
if (docAny.sugarG != null) res.sugarG = docAny.sugarG;
|
||||
if (docAny.sodiumMg != null) res.sodiumMg = docAny.sodiumMg;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
|
|
@ -85,13 +98,14 @@ export default fp(
|
|||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('nutritionTargetService');
|
||||
const userId = request.user.keycloakId;
|
||||
const target = await service.getActiveByUser(userId, request.params.householdId);
|
||||
const params = request.params as { householdId: string };
|
||||
const target = await service.getActiveByUser(userId, params.householdId);
|
||||
|
||||
if (!target) {
|
||||
return reply.status(200).send({ message: 'No active targets defined' });
|
||||
}
|
||||
|
||||
return reply.send(toNutritionTargetResponse(target as AnyTargetDoc));
|
||||
return reply.send(toNutritionTargetResponse(target));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -108,8 +122,9 @@ export default fp(
|
|||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('nutritionTargetService');
|
||||
const userId = request.user.keycloakId;
|
||||
const targets = await service.getAllByUser(userId, request.params.householdId);
|
||||
return reply.send(targets.map((t) => toNutritionTargetResponse(t as AnyTargetDoc)));
|
||||
const params = request.params as { householdId: string };
|
||||
const targets = await service.getAllByUser(userId, params.householdId);
|
||||
return reply.send(targets.map(toNutritionTargetResponse));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -125,8 +140,10 @@ export default fp(
|
|||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('nutritionTargetService');
|
||||
const userId = request.user.keycloakId;
|
||||
const target = await service.setTarget(userId, request.params.householdId, request.body);
|
||||
return reply.status(201).send(toNutritionTargetResponse(target as AnyTargetDoc));
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as SetNutritionTargetInput;
|
||||
const target = await service.setTarget(userId, params.householdId, body);
|
||||
return reply.status(201).send(toNutritionTargetResponse(target));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -144,7 +161,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('nutritionTargetService');
|
||||
const calculated = service.calculatePreset(request.body.calories, request.body.strategy);
|
||||
const body = request.body as { calories: number; strategy: PresetType };
|
||||
const calculated = service.calculatePreset(body.calories, body.strategy);
|
||||
return reply.send(calculated);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { NutritionTargetRepository } from './nutrition-target.repository.js';
|
||||
import type { SetNutritionTargetInput } from '@meshitrack/shared';
|
||||
import type { NutritionTargetDocument } from '../../schemas/nutrition-target.schema.js';
|
||||
|
||||
interface Deps {
|
||||
nutritionTargetRepository: NutritionTargetRepository;
|
||||
|
|
@ -14,15 +15,25 @@ export class NutritionTargetService {
|
|||
this.nutritionTargetRepository = nutritionTargetRepository;
|
||||
}
|
||||
|
||||
public async getActiveByUser(userId: string, householdId: string) {
|
||||
public async getActiveByUser(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<NutritionTargetDocument | null> {
|
||||
return this.nutritionTargetRepository.findByUser(userId, householdId);
|
||||
}
|
||||
|
||||
public async getAllByUser(userId: string, householdId: string) {
|
||||
public async getAllByUser(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<NutritionTargetDocument[]> {
|
||||
return this.nutritionTargetRepository.findAllByUser(userId, householdId);
|
||||
}
|
||||
|
||||
public async setTarget(userId: string, householdId: string, input: SetNutritionTargetInput) {
|
||||
public async setTarget(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
input: SetNutritionTargetInput,
|
||||
): Promise<NutritionTargetDocument> {
|
||||
// Maintain invariant: only one target is active per user per household
|
||||
if (input.isActive !== false) {
|
||||
await this.nutritionTargetRepository.deactivateAllForUser(userId, householdId);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { OrganizerFillModel } from '../../schemas/organizer-fill.schema.js';
|
||||
import type { OrganizerFillDocument } from '../../schemas/organizer-fill.schema.js';
|
||||
import type { OrganizerFillStatus } from '@meshitrack/shared';
|
||||
|
||||
interface OrganizerFillItemData {
|
||||
export interface OrganizerFillItemData {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantityNeeded: number;
|
||||
|
|
@ -11,7 +12,7 @@ interface OrganizerFillItemData {
|
|||
deductions: { cabinetItemId: string; quantityTaken: number }[];
|
||||
}
|
||||
|
||||
interface CreateOrganizerFillData {
|
||||
export interface CreateOrganizerFillData {
|
||||
householdId: string;
|
||||
userId: string;
|
||||
regimenId: string;
|
||||
|
|
@ -23,15 +24,27 @@ interface CreateOrganizerFillData {
|
|||
notes?: string;
|
||||
}
|
||||
|
||||
interface FindByHouseholdQuery {
|
||||
export interface FindByHouseholdQuery {
|
||||
regimenId?: string;
|
||||
status?: OrganizerFillStatus;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface FindByHouseholdResult {
|
||||
data: OrganizerFillDocument[];
|
||||
pagination: {
|
||||
cursor: string | null;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class OrganizerRepository {
|
||||
public async findByHousehold(householdId: string, userId: string, query: FindByHouseholdQuery) {
|
||||
public async findByHousehold(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
query: FindByHouseholdQuery,
|
||||
): Promise<FindByHouseholdResult> {
|
||||
const filter: Record<string, unknown> = { householdId, userId };
|
||||
|
||||
if (query.regimenId) filter['regimenId'] = query.regimenId;
|
||||
|
|
@ -50,28 +63,36 @@ export class OrganizerRepository {
|
|||
.exec();
|
||||
|
||||
const hasMore = items.length > limit;
|
||||
const data = hasMore ? items.slice(0, limit) : items;
|
||||
const rawData = hasMore ? items.slice(0, limit) : items;
|
||||
const data = rawData as unknown as OrganizerFillDocument[];
|
||||
|
||||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return OrganizerFillModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
public async findById(id: string, householdId: string): Promise<OrganizerFillDocument | null> {
|
||||
const doc = await OrganizerFillModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
return doc as unknown as OrganizerFillDocument | null;
|
||||
}
|
||||
|
||||
public async create(data: CreateOrganizerFillData) {
|
||||
public async create(data: CreateOrganizerFillData): Promise<OrganizerFillDocument> {
|
||||
const fill = new OrganizerFillModel(data);
|
||||
const saved = await fill.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as unknown as OrganizerFillDocument;
|
||||
}
|
||||
|
||||
public async updateStatus(id: string, householdId: string, status: OrganizerFillStatus) {
|
||||
return OrganizerFillModel.findOneAndUpdate(
|
||||
public async updateStatus(
|
||||
id: string,
|
||||
householdId: string,
|
||||
status: OrganizerFillStatus,
|
||||
): Promise<OrganizerFillDocument | null> {
|
||||
const doc = await OrganizerFillModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: { status } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as OrganizerFillDocument | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,60 +9,78 @@ import {
|
|||
OrganizerPreviewResponseSchema,
|
||||
OrganizerFillResponseSchema,
|
||||
OrganizerFillListResponseSchema,
|
||||
type OrganizerFillInput,
|
||||
type OrganizerFillQueryInput,
|
||||
type OrganizerPreviewInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { OrganizerRepository } from './organizer.repository.js';
|
||||
import { OrganizerService } from './organizer.service.js';
|
||||
import type { OrganizerFillDocument } from '../../schemas/organizer-fill.schema.js';
|
||||
|
||||
type AnyFillDeduction = {
|
||||
interface SerializedOrganizerDeduction {
|
||||
cabinetItemId: string;
|
||||
quantityTaken: number;
|
||||
};
|
||||
}
|
||||
|
||||
type AnyFillItem = {
|
||||
interface SerializedOrganizerFillItem {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantityNeeded: number;
|
||||
quantityTaken: number;
|
||||
wasShort: boolean;
|
||||
shortage: number;
|
||||
deductions: AnyFillDeduction[];
|
||||
};
|
||||
deductions: SerializedOrganizerDeduction[];
|
||||
}
|
||||
|
||||
type AnyFillDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedOrganizerFillResponse {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
userId: string;
|
||||
regimenId: string;
|
||||
regimenName: string;
|
||||
numberOfDays: number;
|
||||
fillDate: string | Date | { toISOString: () => string };
|
||||
items: AnyFillItem[];
|
||||
status: string;
|
||||
notes?: string | null;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
fillDate: string;
|
||||
items: SerializedOrganizerFillItem[];
|
||||
status: 'completed' | 'partial' | 'reversed';
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
return v.toISOString();
|
||||
}
|
||||
function toFillResponse(doc: OrganizerFillDocument): SerializedOrganizerFillResponse {
|
||||
const docAny = doc as unknown as {
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
fillDate?: { toISOString?: () => string } | string;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
function toFillResponse(doc: AnyFillDoc) {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
const getIsoStr = (d: { toISOString?: () => string } | string | undefined | null): string => {
|
||||
if (!d) return '';
|
||||
if (typeof d === 'string') return d;
|
||||
if (typeof d.toISOString === 'function') return d.toISOString();
|
||||
return String(d);
|
||||
};
|
||||
|
||||
const itemsAny = doc.items as unknown as Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantityNeeded: number;
|
||||
quantityTaken: number;
|
||||
wasShort: boolean;
|
||||
shortage: number;
|
||||
deductions: Array<{ cabinetItemId: string; quantityTaken: number }>;
|
||||
}>;
|
||||
|
||||
const res: SerializedOrganizerFillResponse = {
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
userId: doc.userId,
|
||||
regimenId: doc.regimenId,
|
||||
regimenName: doc.regimenName,
|
||||
numberOfDays: doc.numberOfDays,
|
||||
fillDate: toIso(doc.fillDate),
|
||||
items: doc.items.map((item) => ({
|
||||
fillDate: getIsoStr(docAny.fillDate),
|
||||
items: itemsAny.map((item) => ({
|
||||
medicineId: item.medicineId,
|
||||
medicineName: item.medicineName,
|
||||
quantityNeeded: item.quantityNeeded,
|
||||
|
|
@ -74,11 +92,16 @@ function toFillResponse(doc: AnyFillDoc) {
|
|||
quantityTaken: d.quantityTaken,
|
||||
})),
|
||||
})),
|
||||
status: doc.status,
|
||||
...(doc.notes ? { notes: doc.notes } : {}),
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
status: doc.status as 'completed' | 'partial' | 'reversed',
|
||||
createdAt: getIsoStr(docAny.createdAt),
|
||||
updatedAt: getIsoStr(docAny.updatedAt),
|
||||
};
|
||||
|
||||
if (docAny.notes) {
|
||||
res.notes = docAny.notes;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
|
|
@ -109,11 +132,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const result = await service.listFills(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.query,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as OrganizerFillQueryInput;
|
||||
const result = await service.listFills(params.householdId, request.user.keycloakId, query);
|
||||
return reply.send({
|
||||
data: result.data.map(toFillResponse),
|
||||
pagination: result.pagination,
|
||||
|
|
@ -131,7 +152,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const fill = await service.getFillById(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const fill = await service.getFillById(params.id, params.householdId);
|
||||
return reply.send(toFillResponse(fill));
|
||||
},
|
||||
});
|
||||
|
|
@ -147,11 +169,13 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as OrganizerPreviewInput;
|
||||
const preview = await service.preview(
|
||||
request.params.householdId,
|
||||
params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.body.regimenId,
|
||||
request.body.numberOfDays,
|
||||
body.regimenId,
|
||||
body.numberOfDays,
|
||||
);
|
||||
return reply.send(preview);
|
||||
},
|
||||
|
|
@ -168,11 +192,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const fill = await service.fill(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.body,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as OrganizerFillInput;
|
||||
const fill = await service.fill(params.householdId, request.user.keycloakId, body);
|
||||
return reply.status(201).send(toFillResponse(fill));
|
||||
},
|
||||
});
|
||||
|
|
@ -187,11 +209,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('organizerService');
|
||||
const fill = await service.undoFill(
|
||||
request.params.householdId,
|
||||
request.params.id,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const fill = await service.undoFill(params.householdId, params.id, request.user.keycloakId);
|
||||
return reply.send(toFillResponse(fill));
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import mongoose from 'mongoose';
|
||||
import type { OrganizerRepository } from './organizer.repository.js';
|
||||
import type { OrganizerRepository, FindByHouseholdResult } from './organizer.repository.js';
|
||||
import type { RegimensService } from '../regimens/regimens.service.js';
|
||||
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
|
||||
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
calculateQuantityNeeded,
|
||||
} from '@meshitrack/shared';
|
||||
import type { CreateCabinetEventData } from '../cabinet-events/cabinet-events.repository.js';
|
||||
import type { OrganizerFillDocument } from '../../schemas/organizer-fill.schema.js';
|
||||
import { NotFoundError, BadRequestError } from '../../common/errors.js';
|
||||
|
||||
interface Deps {
|
||||
|
|
@ -21,14 +22,14 @@ interface Deps {
|
|||
cabinetEventsService: CabinetEventsService;
|
||||
}
|
||||
|
||||
interface PreviewDeduction {
|
||||
export interface PreviewDeduction {
|
||||
cabinetItemId: string;
|
||||
expirationDate: string | null;
|
||||
quantityToTake: number;
|
||||
quantityBefore: number;
|
||||
}
|
||||
|
||||
interface PreviewItem {
|
||||
export interface PreviewItem {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantityNeeded: number;
|
||||
|
|
@ -38,6 +39,14 @@ interface PreviewItem {
|
|||
cabinetBreakdown: PreviewDeduction[];
|
||||
}
|
||||
|
||||
export interface PreviewResult {
|
||||
regimenName: string;
|
||||
numberOfDays: number;
|
||||
items: PreviewItem[];
|
||||
canFillCompletely: boolean;
|
||||
hasShortages: boolean;
|
||||
}
|
||||
|
||||
export class OrganizerService {
|
||||
private readonly organizerRepository: OrganizerRepository;
|
||||
private readonly regimensService: RegimensService;
|
||||
|
|
@ -56,11 +65,15 @@ export class OrganizerService {
|
|||
this.cabinetEventsService = cabinetEventsService;
|
||||
}
|
||||
|
||||
public async listFills(householdId: string, userId: string, query: OrganizerFillQueryInput) {
|
||||
public async listFills(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
query: OrganizerFillQueryInput,
|
||||
): Promise<FindByHouseholdResult> {
|
||||
return this.organizerRepository.findByHousehold(householdId, userId, query);
|
||||
}
|
||||
|
||||
public async getFillById(id: string, householdId: string) {
|
||||
public async getFillById(id: string, householdId: string): Promise<OrganizerFillDocument> {
|
||||
const fill = await this.organizerRepository.findById(id, householdId);
|
||||
if (!fill) throw new NotFoundError('Organizer fill not found');
|
||||
return fill;
|
||||
|
|
@ -71,7 +84,7 @@ export class OrganizerService {
|
|||
userId: string,
|
||||
regimenId: string,
|
||||
numberOfDays: number,
|
||||
) {
|
||||
): Promise<PreviewResult> {
|
||||
const regimen = await this.regimensService.getById(regimenId, householdId, userId);
|
||||
if (!regimen.isActive) {
|
||||
throw new BadRequestError('Regimen is not active');
|
||||
|
|
@ -136,7 +149,11 @@ export class OrganizerService {
|
|||
};
|
||||
}
|
||||
|
||||
public async fill(householdId: string, userId: string, input: OrganizerFillInput) {
|
||||
public async fill(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
input: OrganizerFillInput,
|
||||
): Promise<OrganizerFillDocument> {
|
||||
const previewResult = await this.preview(
|
||||
householdId,
|
||||
userId,
|
||||
|
|
@ -233,11 +250,15 @@ export class OrganizerService {
|
|||
await session.abortTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
session.endSession();
|
||||
await session.endSession();
|
||||
}
|
||||
}
|
||||
|
||||
public async undoFill(householdId: string, fillId: string, userId: string) {
|
||||
public async undoFill(
|
||||
householdId: string,
|
||||
fillId: string,
|
||||
userId: string,
|
||||
): Promise<OrganizerFillDocument> {
|
||||
const fill = await this.getFillById(fillId, householdId);
|
||||
|
||||
if (fill.status === OrganizerFillStatus.REVERSED) {
|
||||
|
|
@ -250,7 +271,13 @@ export class OrganizerService {
|
|||
try {
|
||||
session.startTransaction();
|
||||
|
||||
for (const item of fill.items) {
|
||||
const itemsAny = fill.items as unknown as Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
deductions: Array<{ cabinetItemId: string; quantityTaken: number }>;
|
||||
}>;
|
||||
|
||||
for (const item of itemsAny) {
|
||||
for (const deduction of item.deductions) {
|
||||
// Get current quantity before restoring
|
||||
const current = await this.cabinetRepository.findById(
|
||||
|
|
@ -297,7 +324,7 @@ export class OrganizerService {
|
|||
await session.abortTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
session.endSession();
|
||||
await session.endSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { RefillListModel } from '../../schemas/refill-list.schema.js';
|
||||
import type { RefillListDocument } from '../../schemas/refill-list.schema.js';
|
||||
import type {
|
||||
RefillListQueryInput,
|
||||
UpdateRefillListInput,
|
||||
|
|
@ -24,13 +25,19 @@ export interface CreateRefillListData {
|
|||
}
|
||||
|
||||
export class RefillsRepository {
|
||||
public async create(data: CreateRefillListData) {
|
||||
public async create(data: CreateRefillListData): Promise<RefillListDocument> {
|
||||
const list = new RefillListModel(data);
|
||||
const saved = await list.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as RefillListDocument;
|
||||
}
|
||||
|
||||
public async findByHousehold(householdId: string, query: RefillListQueryInput) {
|
||||
public async findByHousehold(
|
||||
householdId: string,
|
||||
query: RefillListQueryInput,
|
||||
): Promise<{
|
||||
data: RefillListDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
const filter: Record<string, unknown> = { householdId };
|
||||
|
||||
if (query.status) filter['status'] = query.status;
|
||||
|
|
@ -52,24 +59,33 @@ export class RefillsRepository {
|
|||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
return {
|
||||
data: data as unknown as RefillListDocument[],
|
||||
pagination: { cursor: hasMore ? cursor : null, hasMore },
|
||||
};
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return RefillListModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
public async findById(id: string, householdId: string): Promise<RefillListDocument | null> {
|
||||
const doc = await RefillListModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
return doc as unknown as RefillListDocument | null;
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateRefillListInput) {
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateRefillListInput,
|
||||
): Promise<RefillListDocument | null> {
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateSet['name'] = data.name;
|
||||
if (data.status !== undefined) updateSet['status'] = data.status;
|
||||
if (data.preferredStoreId !== undefined) updateSet['preferredStoreId'] = data.preferredStoreId;
|
||||
|
||||
return RefillListModel.findOneAndUpdate(
|
||||
const doc = await RefillListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: updateSet },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as RefillListDocument | null;
|
||||
}
|
||||
|
||||
public async updateItem(
|
||||
|
|
@ -77,7 +93,7 @@ export class RefillsRepository {
|
|||
householdId: string,
|
||||
itemId: string,
|
||||
data: UpdateRefillListItemInput & { checkedAt?: Date },
|
||||
) {
|
||||
): Promise<RefillListDocument | null> {
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
if (data.checked !== undefined) updateSet['items.$.checked'] = data.checked;
|
||||
if (data.actualPrice !== undefined) updateSet['items.$.actualPrice'] = data.actualPrice;
|
||||
|
|
@ -85,15 +101,20 @@ export class RefillsRepository {
|
|||
if (data.notes !== undefined) updateSet['items.$.notes'] = data.notes;
|
||||
if (data.checkedAt !== undefined) updateSet['items.$.checkedAt'] = data.checkedAt;
|
||||
|
||||
return RefillListModel.findOneAndUpdate(
|
||||
const doc = await RefillListModel.findOneAndUpdate(
|
||||
{ _id: listId, householdId, 'items._id': itemId },
|
||||
{ $set: updateSet },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as RefillListDocument | null;
|
||||
}
|
||||
|
||||
public async markItemsAddedToCabinet(listId: string, householdId: string, itemIds: string[]) {
|
||||
return RefillListModel.findOneAndUpdate(
|
||||
public async markItemsAddedToCabinet(
|
||||
listId: string,
|
||||
householdId: string,
|
||||
itemIds: string[],
|
||||
): Promise<RefillListDocument | null> {
|
||||
const doc = await RefillListModel.findOneAndUpdate(
|
||||
{ _id: listId, householdId },
|
||||
{ $set: { 'items.$[elem].addedToCabinet': true } },
|
||||
{
|
||||
|
|
@ -102,5 +123,6 @@ export class RefillsRepository {
|
|||
lean: true,
|
||||
},
|
||||
).exec();
|
||||
return doc as unknown as RefillListDocument | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,17 +13,17 @@ import {
|
|||
RefillListListResponseSchema,
|
||||
AddToCabinetResponseSchema,
|
||||
StoreComparisonItemSchema,
|
||||
type CreateRefillListInput,
|
||||
type UpdateRefillListInput,
|
||||
type UpdateRefillListItemInput,
|
||||
type RefillListQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { RefillsRepository } from './refills.repository.js';
|
||||
import { RefillsService } from './refills.service.js';
|
||||
import type { RefillListDocument } from '../../schemas/refill-list.schema.js';
|
||||
|
||||
function toIso(v: Date | string | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
return v.toISOString();
|
||||
}
|
||||
|
||||
type AnyItem = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedRefillListItemResponse {
|
||||
_id: string;
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
quantity: number;
|
||||
|
|
@ -31,57 +31,90 @@ type AnyItem = {
|
|||
estimatedPrice?: number;
|
||||
actualPrice?: number;
|
||||
checked: boolean;
|
||||
checkedAt?: Date | string;
|
||||
checkedAt?: string;
|
||||
addedToCabinet: boolean;
|
||||
storeId?: string;
|
||||
notes?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type AnyRefillList = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedRefillListResponse {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
items: AnyItem[];
|
||||
status: string;
|
||||
items: SerializedRefillListItemResponse[];
|
||||
status: 'active' | 'completed' | 'cancelled';
|
||||
preferredStoreId?: string;
|
||||
totalEstimatedCost?: number;
|
||||
createdBy: string;
|
||||
createdAt: Date | string | { toISOString: () => string };
|
||||
updatedAt: Date | string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toItemResponse(rawItem: unknown) {
|
||||
const item = rawItem as AnyItem;
|
||||
return {
|
||||
_id: typeof item._id === 'string' ? item._id : item._id.toString(),
|
||||
medicineId: item.medicineId,
|
||||
medicineName: item.medicineName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
...(item.estimatedPrice != null ? { estimatedPrice: item.estimatedPrice } : {}),
|
||||
...(item.actualPrice != null ? { actualPrice: item.actualPrice } : {}),
|
||||
checked: item.checked,
|
||||
...(item.checkedAt != null ? { checkedAt: toIso(item.checkedAt) } : {}),
|
||||
addedToCabinet: item.addedToCabinet,
|
||||
...(item.storeId != null ? { storeId: item.storeId } : {}),
|
||||
...(item.notes != null ? { notes: item.notes } : {}),
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toListResponse(rawDoc: unknown) {
|
||||
const doc = rawDoc as AnyRefillList;
|
||||
return {
|
||||
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||
function toListResponse(doc: RefillListDocument): SerializedRefillListResponse {
|
||||
const docAny = doc as unknown as {
|
||||
preferredStoreId?: string | null;
|
||||
totalEstimatedCost?: number | null;
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
};
|
||||
|
||||
const getIsoStr = (d: { toISOString?: () => string } | string | undefined | null): string => {
|
||||
if (!d) return '';
|
||||
if (typeof d === 'string') return d;
|
||||
if (typeof d.toISOString === 'function') return d.toISOString();
|
||||
return String(d);
|
||||
};
|
||||
|
||||
const response: SerializedRefillListResponse = {
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
name: doc.name,
|
||||
items: doc.items.map(toItemResponse),
|
||||
status: doc.status as never,
|
||||
...(doc.preferredStoreId != null ? { preferredStoreId: doc.preferredStoreId } : {}),
|
||||
...(doc.totalEstimatedCost != null ? { totalEstimatedCost: doc.totalEstimatedCost } : {}),
|
||||
items: doc.items.map((item) => {
|
||||
const itemAny = item as unknown as {
|
||||
_id: { toString: () => string };
|
||||
estimatedPrice?: number | null;
|
||||
actualPrice?: number | null;
|
||||
checkedAt?: { toISOString?: () => string } | string | Date;
|
||||
storeId?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
const itemResponse: SerializedRefillListItemResponse = {
|
||||
_id: itemAny._id.toString(),
|
||||
medicineId: item.medicineId,
|
||||
medicineName: item.medicineName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
checked: item.checked,
|
||||
addedToCabinet: item.addedToCabinet,
|
||||
};
|
||||
|
||||
if (itemAny.estimatedPrice != null) itemResponse.estimatedPrice = itemAny.estimatedPrice;
|
||||
if (itemAny.actualPrice != null) itemResponse.actualPrice = itemAny.actualPrice;
|
||||
if (itemAny.checkedAt) {
|
||||
if (typeof itemAny.checkedAt === 'string') {
|
||||
itemResponse.checkedAt = itemAny.checkedAt;
|
||||
} else if (itemAny.checkedAt instanceof Date) {
|
||||
itemResponse.checkedAt = itemAny.checkedAt.toISOString();
|
||||
} else if (typeof itemAny.checkedAt.toISOString === 'function') {
|
||||
itemResponse.checkedAt = itemAny.checkedAt.toISOString();
|
||||
}
|
||||
}
|
||||
if (itemAny.storeId) itemResponse.storeId = itemAny.storeId;
|
||||
if (itemAny.notes) itemResponse.notes = itemAny.notes;
|
||||
|
||||
return itemResponse;
|
||||
}),
|
||||
status: doc.status as 'active' | 'completed' | 'cancelled',
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
createdAt: getIsoStr(docAny.createdAt),
|
||||
updatedAt: getIsoStr(docAny.updatedAt),
|
||||
};
|
||||
|
||||
if (docAny.preferredStoreId) response.preferredStoreId = docAny.preferredStoreId;
|
||||
if (docAny.totalEstimatedCost != null) response.totalEstimatedCost = docAny.totalEstimatedCost;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
|
|
@ -111,19 +144,21 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as { userId?: string; thresholdDays?: number };
|
||||
const alerts = await service.getAlerts(
|
||||
request.params.householdId,
|
||||
request.query.userId ?? request.user.keycloakId,
|
||||
request.query.thresholdDays,
|
||||
params.householdId,
|
||||
query.userId ?? request.user.keycloakId,
|
||||
query.thresholdDays,
|
||||
);
|
||||
return reply.send({
|
||||
data: alerts.map((a) => ({
|
||||
...a,
|
||||
lastKnownPrice: a.lastKnownPrice
|
||||
? { ...a.lastKnownPrice, date: toIso(a.lastKnownPrice.date) }
|
||||
? { ...a.lastKnownPrice, date: a.lastKnownPrice.date.toISOString() }
|
||||
: undefined,
|
||||
cheapestOption: a.cheapestOption
|
||||
? { ...a.cheapestOption, date: toIso(a.cheapestOption.date) }
|
||||
? { ...a.cheapestOption, date: a.cheapestOption.date.toISOString() }
|
||||
: undefined,
|
||||
})),
|
||||
});
|
||||
|
|
@ -140,11 +175,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const list = await service.createList(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as CreateRefillListInput;
|
||||
const list = await service.createList(body, params.householdId, request.user.keycloakId);
|
||||
return reply.status(201).send(toListResponse(list));
|
||||
},
|
||||
});
|
||||
|
|
@ -159,7 +192,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as RefillListQueryInput;
|
||||
const result = await service.list(params.householdId, query);
|
||||
return reply.send({
|
||||
data: result.data.map(toListResponse),
|
||||
pagination: result.pagination,
|
||||
|
|
@ -176,7 +211,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const list = await service.getById(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const list = await service.getById(params.id, params.householdId);
|
||||
return reply.send(toListResponse(list));
|
||||
},
|
||||
});
|
||||
|
|
@ -191,11 +227,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const list = await service.updateList(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as UpdateRefillListInput;
|
||||
const list = await service.updateList(params.id, params.householdId, body);
|
||||
return reply.send(toListResponse(list));
|
||||
},
|
||||
});
|
||||
|
|
@ -210,12 +244,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const list = await service.updateItem(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.params.itemId,
|
||||
request.body,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string; itemId: string };
|
||||
const body = request.body as UpdateRefillListItemInput;
|
||||
const list = await service.updateItem(params.id, params.householdId, params.itemId, body);
|
||||
return reply.send(toListResponse(list));
|
||||
},
|
||||
});
|
||||
|
|
@ -229,9 +260,10 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const result = await service.addToCabinet(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
params.id,
|
||||
params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(result);
|
||||
|
|
@ -256,16 +288,14 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('refillsService');
|
||||
const comparisons = await service.getStoreComparison(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const comparisons = await service.getStoreComparison(params.id, params.householdId);
|
||||
return reply.send({
|
||||
data: comparisons.map((c) => ({
|
||||
medicineId: c.medicineId,
|
||||
storeOptions: c.storeOptions.map((opt) => ({
|
||||
...opt,
|
||||
date: toIso(opt.date),
|
||||
date: opt.date.toISOString(),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import type { RegimensService } from '../regimens/regimens.service.js';
|
|||
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
|
||||
import type { CabinetService } from '../cabinet/cabinet.service.js';
|
||||
import type { MedicinePricesRepository } from '../medicine-prices/medicine-prices.repository.js';
|
||||
import type { PurchasesRepository } from '../purchases/purchases.repository.js';
|
||||
import type { ShoppingListsRepository } from '../shopping-lists/shopping-lists.repository.js';
|
||||
import type { RefillListDocument } from '../../schemas/refill-list.schema.js';
|
||||
import type {
|
||||
CreateRefillListInput,
|
||||
UpdateRefillListInput,
|
||||
|
|
@ -19,7 +20,46 @@ interface Deps {
|
|||
cabinetRepository: CabinetRepository;
|
||||
cabinetService: CabinetService;
|
||||
medicinePricesRepository: MedicinePricesRepository;
|
||||
purchasesRepository: PurchasesRepository;
|
||||
shoppingListsRepository: ShoppingListsRepository;
|
||||
}
|
||||
|
||||
interface RefillAlert {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: string;
|
||||
daysUntilEmpty: number;
|
||||
dailyConsumption: number;
|
||||
currentStock: number;
|
||||
pendingOrderStock: number;
|
||||
daysUntilEmptyWithOrders: number | null;
|
||||
suggestedQuantity: number;
|
||||
lastKnownPrice?: {
|
||||
price: number;
|
||||
pricePerUnit: number;
|
||||
storeName: string;
|
||||
storeId: string;
|
||||
date: Date;
|
||||
};
|
||||
cheapestOption?: {
|
||||
price: number;
|
||||
pricePerUnit: number;
|
||||
storeName: string;
|
||||
storeId: string;
|
||||
date: Date;
|
||||
};
|
||||
}
|
||||
|
||||
interface CabinetAggregateSummaryGroup {
|
||||
_id: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: string;
|
||||
medicineForm: string;
|
||||
totalQuantity: number;
|
||||
unit: string;
|
||||
earliestExpiry: Date | null;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export class RefillsService {
|
||||
|
|
@ -28,7 +68,7 @@ export class RefillsService {
|
|||
private readonly cabinetRepository: CabinetRepository;
|
||||
private readonly cabinetService: CabinetService;
|
||||
private readonly medicinePricesRepository: MedicinePricesRepository;
|
||||
private readonly purchasesRepository: PurchasesRepository;
|
||||
private readonly shoppingListsRepository: ShoppingListsRepository;
|
||||
|
||||
public constructor({
|
||||
refillsRepository,
|
||||
|
|
@ -36,17 +76,21 @@ export class RefillsService {
|
|||
cabinetRepository,
|
||||
cabinetService,
|
||||
medicinePricesRepository,
|
||||
purchasesRepository,
|
||||
shoppingListsRepository,
|
||||
}: Deps) {
|
||||
this.refillsRepository = refillsRepository;
|
||||
this.regimensService = regimensService;
|
||||
this.cabinetRepository = cabinetRepository;
|
||||
this.cabinetService = cabinetService;
|
||||
this.medicinePricesRepository = medicinePricesRepository;
|
||||
this.purchasesRepository = purchasesRepository;
|
||||
this.shoppingListsRepository = shoppingListsRepository;
|
||||
}
|
||||
|
||||
public async getAlerts(householdId: string, userId: string, thresholdDays = 7) {
|
||||
public async getAlerts(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
thresholdDays = 7,
|
||||
): Promise<RefillAlert[]> {
|
||||
const burnRates = await this.regimensService.calculateBurnRates(householdId, userId);
|
||||
|
||||
const triggered = burnRates.filter(
|
||||
|
|
@ -56,23 +100,36 @@ export class RefillsService {
|
|||
if (triggered.length === 0) return [];
|
||||
|
||||
// Get strength data from cabinet aggregate
|
||||
const summaries = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||
const summariesRaw = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||
const summaries = summariesRaw as unknown as CabinetAggregateSummaryGroup[];
|
||||
const summaryMap = new Map<
|
||||
string,
|
||||
{ medicineStrength: number; medicineStrengthUnit: string }
|
||||
>();
|
||||
for (const s of summaries) {
|
||||
summaryMap.set(s._id as string, {
|
||||
medicineStrength: s.medicineStrength as number,
|
||||
medicineStrengthUnit: s.medicineStrengthUnit as string,
|
||||
summaryMap.set(s._id, {
|
||||
medicineStrength: s.medicineStrength,
|
||||
medicineStrengthUnit: s.medicineStrengthUnit,
|
||||
});
|
||||
}
|
||||
|
||||
// Get pending stock from ordered purchases
|
||||
const pendingStockRows = await this.purchasesRepository.getPendingMedicineStock(householdId);
|
||||
// Get pending stock from active shopping lists
|
||||
const activeLists = await this.shoppingListsRepository.findActiveByHousehold(householdId);
|
||||
const pendingStockMap = new Map<string, number>();
|
||||
for (const row of pendingStockRows) {
|
||||
pendingStockMap.set(row.medicineId, row.totalUnits);
|
||||
for (const list of activeLists) {
|
||||
if (list.items) {
|
||||
for (const item of list.items) {
|
||||
const itemAny = item as unknown as {
|
||||
productId?: string;
|
||||
quantity: number;
|
||||
checked: boolean;
|
||||
};
|
||||
if (itemAny.productId && !itemAny.checked) {
|
||||
const currentQty = pendingStockMap.get(itemAny.productId) ?? 0;
|
||||
pendingStockMap.set(itemAny.productId, currentQty + itemAny.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const alerts = await Promise.all(
|
||||
|
|
@ -94,7 +151,7 @@ export class RefillsService {
|
|||
medicineId: br.medicineId,
|
||||
medicineName: br.medicineName,
|
||||
medicineStrength: summary?.medicineStrength ?? 0,
|
||||
medicineStrengthUnit: (summary?.medicineStrengthUnit ?? 'mg') as never,
|
||||
medicineStrengthUnit: summary?.medicineStrengthUnit ?? 'mg',
|
||||
daysUntilEmpty: br.daysUntilEmpty as number,
|
||||
dailyConsumption: br.dailyConsumption,
|
||||
currentStock: br.totalInCabinet,
|
||||
|
|
@ -103,11 +160,11 @@ export class RefillsService {
|
|||
suggestedQuantity,
|
||||
lastKnownPrice: latestRecord
|
||||
? {
|
||||
price: latestRecord.price as number,
|
||||
pricePerUnit: latestRecord.pricePerUnit as number,
|
||||
storeName: latestRecord.storeName as string,
|
||||
storeId: latestRecord.storeId as string,
|
||||
date: latestRecord.date as Date,
|
||||
price: latestRecord.price,
|
||||
pricePerUnit: latestRecord.pricePerUnit,
|
||||
storeName: latestRecord.storeName,
|
||||
storeId: latestRecord.storeId,
|
||||
date: latestRecord.date,
|
||||
}
|
||||
: undefined,
|
||||
cheapestOption:
|
||||
|
|
@ -127,7 +184,11 @@ export class RefillsService {
|
|||
return alerts;
|
||||
}
|
||||
|
||||
public async createList(data: CreateRefillListInput, householdId: string, userId: string) {
|
||||
public async createList(
|
||||
data: CreateRefillListInput,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<RefillListDocument> {
|
||||
let items: Array<{
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
|
|
@ -144,7 +205,7 @@ export class RefillsService {
|
|||
medicineId: alert.medicineId,
|
||||
medicineName: alert.medicineName,
|
||||
quantity: alert.suggestedQuantity,
|
||||
unit: 'tablet' as string,
|
||||
unit: 'tablet',
|
||||
estimatedPrice: alert.cheapestOption?.price ?? alert.lastKnownPrice?.price,
|
||||
storeId: alert.cheapestOption?.storeId ?? alert.lastKnownPrice?.storeId,
|
||||
}));
|
||||
|
|
@ -153,7 +214,7 @@ export class RefillsService {
|
|||
medicineId: item.medicineId,
|
||||
medicineName: item.medicineName,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit as string,
|
||||
unit: item.unit,
|
||||
estimatedPrice: item.estimatedPrice,
|
||||
storeId: item.storeId,
|
||||
notes: item.notes,
|
||||
|
|
@ -176,17 +237,27 @@ export class RefillsService {
|
|||
});
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: RefillListQueryInput) {
|
||||
public async list(
|
||||
householdId: string,
|
||||
query: RefillListQueryInput,
|
||||
): Promise<{
|
||||
data: RefillListDocument[];
|
||||
pagination: { cursor: string | null; hasMore: boolean };
|
||||
}> {
|
||||
return this.refillsRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
public async getById(id: string, householdId: string): Promise<RefillListDocument> {
|
||||
const list = await this.refillsRepository.findById(id, householdId);
|
||||
if (!list) throw new NotFoundError('Refill list not found');
|
||||
return list;
|
||||
}
|
||||
|
||||
public async updateList(id: string, householdId: string, data: UpdateRefillListInput) {
|
||||
public async updateList(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateRefillListInput,
|
||||
): Promise<RefillListDocument> {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.refillsRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Refill list not found');
|
||||
|
|
@ -198,7 +269,7 @@ export class RefillsService {
|
|||
householdId: string,
|
||||
itemId: string,
|
||||
data: UpdateRefillListItemInput,
|
||||
) {
|
||||
): Promise<RefillListDocument> {
|
||||
await this.getById(listId, householdId);
|
||||
|
||||
const updateData: UpdateRefillListItemInput & { checkedAt?: Date } = { ...data };
|
||||
|
|
@ -216,7 +287,11 @@ export class RefillsService {
|
|||
return updated;
|
||||
}
|
||||
|
||||
public async addToCabinet(listId: string, householdId: string, userId: string) {
|
||||
public async addToCabinet(
|
||||
listId: string,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<{ addedCount: number; priceRecordsCreated: number }> {
|
||||
const list = await this.getById(listId, householdId);
|
||||
|
||||
const checkedItems = (
|
||||
|
|
@ -266,7 +341,23 @@ export class RefillsService {
|
|||
return { addedCount, priceRecordsCreated: 0 };
|
||||
}
|
||||
|
||||
public async getStoreComparison(listId: string, householdId: string) {
|
||||
public async getStoreComparison(
|
||||
listId: string,
|
||||
householdId: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
medicineId: string;
|
||||
storeOptions: Array<{
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
latestPrice: number;
|
||||
latestPricePerUnit: number;
|
||||
currency: string;
|
||||
date: Date;
|
||||
isInsurancePrice: boolean;
|
||||
}>;
|
||||
}>
|
||||
> {
|
||||
const list = await this.getById(listId, householdId);
|
||||
|
||||
const medicineIds = [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { RegimenModel } from '../../schemas/regimen.schema.js';
|
||||
import type { RegimenDocument } from '../../schemas/regimen.schema.js';
|
||||
|
||||
interface FindByHouseholdQuery {
|
||||
isActive?: boolean;
|
||||
|
|
@ -33,7 +34,11 @@ interface UpdateRegimenData {
|
|||
}
|
||||
|
||||
export class RegimensRepository {
|
||||
public async findByHousehold(householdId: string, userId: string, query: FindByHouseholdQuery) {
|
||||
public async findByHousehold(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
query: FindByHouseholdQuery,
|
||||
): Promise<{ data: RegimenDocument[]; pagination: { cursor: string | null; hasMore: boolean } }> {
|
||||
const filter: Record<string, unknown> = { householdId, userId, isDeleted: false };
|
||||
|
||||
if (query.isActive !== undefined) filter['isActive'] = query.isActive;
|
||||
|
|
@ -55,18 +60,29 @@ export class RegimensRepository {
|
|||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
return {
|
||||
data: data as unknown as RegimenDocument[],
|
||||
pagination: { cursor: hasMore ? cursor : null, hasMore },
|
||||
};
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string, userId: string) {
|
||||
return RegimenModel.findOne({ _id: id, householdId, userId, isDeleted: false }).lean().exec();
|
||||
public async findById(
|
||||
id: string,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<RegimenDocument | null> {
|
||||
const doc = await RegimenModel.findOne({ _id: id, householdId, userId, isDeleted: false })
|
||||
.lean()
|
||||
.exec();
|
||||
return doc as unknown as RegimenDocument | null;
|
||||
}
|
||||
|
||||
public async findActiveByUser(householdId: string, userId: string) {
|
||||
return RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false })
|
||||
public async findActiveByUser(householdId: string, userId: string): Promise<RegimenDocument[]> {
|
||||
const docs = await RegimenModel.find({ householdId, userId, isActive: true, isDeleted: false })
|
||||
.sort({ name: 1, _id: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return docs as unknown as RegimenDocument[];
|
||||
}
|
||||
|
||||
public async create(
|
||||
|
|
@ -74,25 +90,36 @@ export class RegimensRepository {
|
|||
householdId: string,
|
||||
userId: string,
|
||||
createdBy: string,
|
||||
) {
|
||||
): Promise<RegimenDocument> {
|
||||
const regimen = new RegimenModel({ ...data, householdId, userId, createdBy });
|
||||
const saved = await regimen.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as RegimenDocument;
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, userId: string, data: UpdateRegimenData) {
|
||||
return RegimenModel.findOneAndUpdate(
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
data: UpdateRegimenData,
|
||||
): Promise<RegimenDocument | null> {
|
||||
const doc = await RegimenModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, userId, isDeleted: false },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as RegimenDocument | null;
|
||||
}
|
||||
|
||||
public async softDelete(id: string, householdId: string, userId: string) {
|
||||
return RegimenModel.findOneAndUpdate(
|
||||
public async softDelete(
|
||||
id: string,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<RegimenDocument | null> {
|
||||
const doc = await RegimenModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, userId, isDeleted: false },
|
||||
{ $set: { isDeleted: true } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as RegimenDocument | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,15 @@ import {
|
|||
RegimenResponseSchema,
|
||||
RegimenListResponseSchema,
|
||||
BurnRateResponseSchema,
|
||||
type CreateRegimenInput,
|
||||
type UpdateRegimenInput,
|
||||
type RegimenQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { RegimensRepository } from './regimens.repository.js';
|
||||
import { RegimensService } from './regimens.service.js';
|
||||
import type { RegimenDocument } from '../../schemas/regimen.schema.js';
|
||||
|
||||
type AnyRegimenMedication = {
|
||||
interface SerializedRegimenMedication {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
|
|
@ -22,58 +26,74 @@ type AnyRegimenMedication = {
|
|||
dosage: number;
|
||||
dosageUnit: string;
|
||||
frequency: string;
|
||||
customFrequencyPerDay?: number | null;
|
||||
timeOfDay?: string | null;
|
||||
instructions?: string | null;
|
||||
};
|
||||
customFrequencyPerDay?: number;
|
||||
timeOfDay?: string;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
type AnyRegimenDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedRegimenResponse {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
medications: AnyRegimenMedication[];
|
||||
medications: SerializedRegimenMedication[];
|
||||
createdBy: string;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toStr(v: string | { toString: () => string }): string {
|
||||
return typeof v === 'string' ? v : v.toString();
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
return v.toISOString();
|
||||
}
|
||||
function toRegimenResponse(doc: RegimenDocument): SerializedRegimenResponse {
|
||||
const docAny = doc as unknown as {
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
};
|
||||
|
||||
const getIsoStr = (d: { toISOString?: () => string } | string | undefined | null): string => {
|
||||
if (!d) return '';
|
||||
if (typeof d === 'string') return d;
|
||||
if (typeof d.toISOString === 'function') return d.toISOString();
|
||||
return String(d);
|
||||
};
|
||||
|
||||
function toRegimenResponse(doc: AnyRegimenDoc) {
|
||||
return {
|
||||
_id: toStr(doc._id),
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
userId: doc.userId,
|
||||
name: doc.name,
|
||||
isActive: doc.isActive,
|
||||
medications: doc.medications.map((med) => ({
|
||||
medicineId: med.medicineId,
|
||||
medicineName: med.medicineName,
|
||||
medicineStrength: med.medicineStrength,
|
||||
medicineStrengthUnit: med.medicineStrengthUnit,
|
||||
medicineForm: med.medicineForm,
|
||||
dosage: med.dosage,
|
||||
dosageUnit: med.dosageUnit,
|
||||
frequency: med.frequency,
|
||||
...(med.customFrequencyPerDay != null
|
||||
? { customFrequencyPerDay: med.customFrequencyPerDay }
|
||||
: {}),
|
||||
...(med.timeOfDay ? { timeOfDay: med.timeOfDay } : {}),
|
||||
...(med.instructions ? { instructions: med.instructions } : {}),
|
||||
})),
|
||||
medications: doc.medications.map((med) => {
|
||||
const medAny = med as unknown as {
|
||||
customFrequencyPerDay?: number | null;
|
||||
timeOfDay?: string | null;
|
||||
instructions?: string | null;
|
||||
};
|
||||
const response: SerializedRegimenMedication = {
|
||||
medicineId: med.medicineId,
|
||||
medicineName: med.medicineName,
|
||||
medicineStrength: med.medicineStrength,
|
||||
medicineStrengthUnit: med.medicineStrengthUnit,
|
||||
medicineForm: med.medicineForm,
|
||||
dosage: med.dosage,
|
||||
dosageUnit: med.dosageUnit,
|
||||
frequency: med.frequency,
|
||||
};
|
||||
|
||||
if (medAny.customFrequencyPerDay != null) {
|
||||
response.customFrequencyPerDay = medAny.customFrequencyPerDay;
|
||||
}
|
||||
if (medAny.timeOfDay) {
|
||||
response.timeOfDay = medAny.timeOfDay;
|
||||
}
|
||||
if (medAny.instructions) {
|
||||
response.instructions = medAny.instructions;
|
||||
}
|
||||
|
||||
return response;
|
||||
}),
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
createdAt: getIsoStr(docAny.createdAt),
|
||||
updatedAt: getIsoStr(docAny.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -105,11 +125,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const result = await service.list(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.query,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as RegimenQueryInput;
|
||||
const result = await service.list(params.householdId, request.user.keycloakId, query);
|
||||
return reply.send({
|
||||
data: result.data.map(toRegimenResponse),
|
||||
pagination: result.pagination,
|
||||
|
|
@ -127,10 +145,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const data = await service.calculateBurnRates(
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const data = await service.calculateBurnRates(params.householdId, request.user.keycloakId);
|
||||
return reply.send({ data });
|
||||
},
|
||||
});
|
||||
|
|
@ -145,9 +161,10 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const regimen = await service.getById(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
params.id,
|
||||
params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
return reply.send(toRegimenResponse(regimen));
|
||||
|
|
@ -165,11 +182,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const regimen = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as CreateRegimenInput;
|
||||
const regimen = await service.create(body, params.householdId, request.user.keycloakId);
|
||||
return reply.status(201).send(toRegimenResponse(regimen));
|
||||
},
|
||||
});
|
||||
|
|
@ -185,11 +200,13 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as UpdateRegimenInput;
|
||||
const regimen = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
params.id,
|
||||
params.householdId,
|
||||
request.user.keycloakId,
|
||||
request.body,
|
||||
body,
|
||||
);
|
||||
return reply.send(toRegimenResponse(regimen));
|
||||
},
|
||||
|
|
@ -205,11 +222,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('regimensService');
|
||||
await service.delete(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
await service.delete(params.id, params.householdId, request.user.keycloakId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { RegimensRepository } from './regimens.repository.js';
|
|||
import type { MedicinesRepository } from '../medicines/medicines.repository.js';
|
||||
import type { CabinetRepository } from '../cabinet/cabinet.repository.js';
|
||||
import type { CabinetEventsService } from '../cabinet-events/cabinet-events.service.js';
|
||||
import type { RegimenDocument } from '../../schemas/regimen.schema.js';
|
||||
import type { CreateRegimenInput, UpdateRegimenInput, RegimenQueryInput } from '@meshitrack/shared';
|
||||
import { getFrequencyMultiplier } from '@meshitrack/shared';
|
||||
import type { DosageFrequency } from '@meshitrack/shared';
|
||||
|
|
@ -14,6 +15,46 @@ interface Deps {
|
|||
cabinetEventsService: CabinetEventsService;
|
||||
}
|
||||
|
||||
interface RegimenMedicationData {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: string;
|
||||
medicineForm: string;
|
||||
dosage: number;
|
||||
dosageUnit: string;
|
||||
frequency: string;
|
||||
customFrequencyPerDay?: number;
|
||||
timeOfDay?: string;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
interface CabinetAggregateSummaryGroup {
|
||||
_id: string;
|
||||
medicineName: string;
|
||||
medicineStrength: number;
|
||||
medicineStrengthUnit: string;
|
||||
medicineForm: string;
|
||||
totalQuantity: number;
|
||||
unit: string;
|
||||
earliestExpiry: Date | null;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
interface BurnRateItem {
|
||||
medicineId: string;
|
||||
medicineName: string;
|
||||
dailyConsumption: number;
|
||||
totalInCabinet: number;
|
||||
daysUntilEmpty: number | null;
|
||||
earliestExpiry: string | null;
|
||||
avgUnitPrice: number | null;
|
||||
projectedDailyCost: number | null;
|
||||
projectedMonthlyCost: number | null;
|
||||
projectedYearlyCost: number | null;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
export class RegimensService {
|
||||
private readonly regimensRepository: RegimensRepository;
|
||||
private readonly medicinesRepository: MedicinesRepository;
|
||||
|
|
@ -32,17 +73,25 @@ export class RegimensService {
|
|||
this.cabinetEventsService = cabinetEventsService;
|
||||
}
|
||||
|
||||
public async list(householdId: string, userId: string, query: RegimenQueryInput) {
|
||||
public async list(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
query: RegimenQueryInput,
|
||||
): Promise<{ data: RegimenDocument[]; pagination: { cursor: string | null; hasMore: boolean } }> {
|
||||
return this.regimensRepository.findByHousehold(householdId, userId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string, userId: string) {
|
||||
public async getById(id: string, householdId: string, userId: string): Promise<RegimenDocument> {
|
||||
const regimen = await this.regimensRepository.findById(id, householdId, userId);
|
||||
if (!regimen) throw new NotFoundError('Regimen not found');
|
||||
return regimen;
|
||||
}
|
||||
|
||||
public async create(data: CreateRegimenInput, householdId: string, userId: string) {
|
||||
public async create(
|
||||
data: CreateRegimenInput,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<RegimenDocument> {
|
||||
const medications = await this.denormalizeMedications(data.medications, householdId);
|
||||
return this.regimensRepository.create(
|
||||
{ name: data.name, isActive: data.isActive, medications },
|
||||
|
|
@ -52,14 +101,23 @@ export class RegimensService {
|
|||
);
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, userId: string, data: UpdateRegimenInput) {
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
data: UpdateRegimenInput,
|
||||
): Promise<RegimenDocument> {
|
||||
await this.getById(id, householdId, userId);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateData['name'] = data.name;
|
||||
if (data.isActive !== undefined) updateData['isActive'] = data.isActive;
|
||||
const updateData: {
|
||||
name?: string;
|
||||
isActive?: boolean;
|
||||
medications?: RegimenMedicationData[];
|
||||
} = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
||||
if (data.medications !== undefined) {
|
||||
updateData['medications'] = await this.denormalizeMedications(data.medications, householdId);
|
||||
updateData.medications = await this.denormalizeMedications(data.medications, householdId);
|
||||
}
|
||||
|
||||
const updated = await this.regimensRepository.update(id, householdId, userId, updateData);
|
||||
|
|
@ -67,18 +125,18 @@ export class RegimensService {
|
|||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string, userId: string) {
|
||||
public async delete(id: string, householdId: string, userId: string): Promise<RegimenDocument> {
|
||||
await this.getById(id, householdId, userId);
|
||||
const deleted = await this.regimensRepository.softDelete(id, householdId, userId);
|
||||
if (!deleted) throw new NotFoundError('Regimen not found');
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public async getActiveByUser(householdId: string, userId: string) {
|
||||
public async getActiveByUser(householdId: string, userId: string): Promise<RegimenDocument[]> {
|
||||
return this.regimensRepository.findActiveByUser(householdId, userId);
|
||||
}
|
||||
|
||||
public async calculateBurnRates(householdId: string, userId: string) {
|
||||
public async calculateBurnRates(householdId: string, userId: string): Promise<BurnRateItem[]> {
|
||||
const regimens = await this.regimensRepository.findActiveByUser(householdId, userId);
|
||||
|
||||
// Sum daily consumption per medicine across all active regimens
|
||||
|
|
@ -111,12 +169,13 @@ export class RegimensService {
|
|||
if (consumptionMap.size === 0) return [];
|
||||
|
||||
// Get cabinet summary for all medicines in regimens
|
||||
const summaryResults = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||
const summaryResultsRaw = await this.cabinetRepository.getAggregateSummary(householdId);
|
||||
const summaryResults = summaryResultsRaw as unknown as CabinetAggregateSummaryGroup[];
|
||||
const stockMap = new Map<string, { totalQuantity: number; earliestExpiry: Date | null }>();
|
||||
for (const s of summaryResults) {
|
||||
stockMap.set(s._id as string, {
|
||||
totalQuantity: s.totalQuantity as number,
|
||||
earliestExpiry: (s.earliestExpiry as Date | null) ?? null,
|
||||
stockMap.set(s._id, {
|
||||
totalQuantity: s.totalQuantity,
|
||||
earliestExpiry: s.earliestExpiry,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +184,7 @@ export class RegimensService {
|
|||
const priceMap = await this.cabinetEventsService.getAvgUnitPrices(householdId, medicineIds);
|
||||
|
||||
// Build burn rate array
|
||||
const burnRates = [];
|
||||
const burnRates: BurnRateItem[] = [];
|
||||
for (const [medicineId, consumption] of consumptionMap) {
|
||||
const stock = stockMap.get(medicineId);
|
||||
const totalInCabinet = stock?.totalQuantity ?? 0;
|
||||
|
|
@ -174,8 +233,8 @@ export class RegimensService {
|
|||
private async denormalizeMedications(
|
||||
medications: CreateRegimenInput['medications'],
|
||||
householdId: string,
|
||||
) {
|
||||
const result = [];
|
||||
): Promise<RegimenMedicationData[]> {
|
||||
const result: RegimenMedicationData[] = [];
|
||||
for (const med of medications) {
|
||||
const medicine = await this.medicinesRepository.findById(med.medicineId, householdId);
|
||||
if (!medicine) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,118 @@
|
|||
import { ShoppingListModel } from '../../schemas/shopping-list.schema.js';
|
||||
import type {
|
||||
UpdateShoppingListInput,
|
||||
ShoppingItem,
|
||||
} from '@meshitrack/shared';
|
||||
import type { ShoppingListDocument } from '../../schemas/shopping-list.schema.js';
|
||||
import type { UpdateShoppingListInput, ShoppingItem } from '@meshitrack/shared';
|
||||
|
||||
export class ShoppingListsRepository {
|
||||
private sortItems(list: any) {
|
||||
public async create(
|
||||
data: Omit<ShoppingListDocument, '_id' | 'createdAt' | 'updatedAt'>,
|
||||
): Promise<ShoppingListDocument> {
|
||||
const list = new ShoppingListModel(data);
|
||||
const saved = await list.save();
|
||||
return this.sortItems(saved.toObject() as ShoppingListDocument);
|
||||
}
|
||||
|
||||
public async list(householdId: string): Promise<ShoppingListDocument[]> {
|
||||
const lists = await ShoppingListModel.find({ householdId })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return (lists as unknown as ShoppingListDocument[]).map((l) => this.sortItems(l));
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string): Promise<ShoppingListDocument | null> {
|
||||
const list = await ShoppingListModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
return this.sortItems(list as unknown as ShoppingListDocument | null);
|
||||
}
|
||||
|
||||
public async findActiveByHousehold(householdId: string): Promise<ShoppingListDocument[]> {
|
||||
const lists = await ShoppingListModel.find({
|
||||
householdId,
|
||||
status: { $in: ['active', 'shopping'] },
|
||||
})
|
||||
.sort({ updatedAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return (lists as unknown as ShoppingListDocument[]).map((l) => this.sortItems(l));
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateShoppingListInput,
|
||||
): Promise<ShoppingListDocument | null> {
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: data },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
return this.sortItems(updated as unknown as ShoppingListDocument | null);
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string): Promise<ShoppingListDocument | null> {
|
||||
const deleted = await ShoppingListModel.findOneAndDelete({ _id: id, householdId })
|
||||
.lean()
|
||||
.exec();
|
||||
return deleted as unknown as ShoppingListDocument | null;
|
||||
}
|
||||
|
||||
// --- Granular Atomic Subdocument Actions ---
|
||||
|
||||
public async addItem(
|
||||
id: string,
|
||||
householdId: string,
|
||||
item: ShoppingItem,
|
||||
): Promise<ShoppingListDocument | null> {
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $push: { items: item } },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
return this.sortItems(updated as unknown as ShoppingListDocument | null);
|
||||
}
|
||||
|
||||
public async updateItem(
|
||||
id: string,
|
||||
householdId: string,
|
||||
itemId: string,
|
||||
updates: Partial<ShoppingItem>,
|
||||
): Promise<ShoppingListDocument | null> {
|
||||
const setUpdates: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(updates)) {
|
||||
setUpdates[`items.$.${key}`] = val;
|
||||
}
|
||||
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, 'items.id': itemId },
|
||||
{ $set: setUpdates },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
return this.sortItems(updated as unknown as ShoppingListDocument | null);
|
||||
}
|
||||
|
||||
public async removeItem(
|
||||
id: string,
|
||||
householdId: string,
|
||||
itemId: string,
|
||||
): Promise<ShoppingListDocument | null> {
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $pull: { items: { id: itemId } } },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
return this.sortItems(updated as unknown as ShoppingListDocument | null);
|
||||
}
|
||||
|
||||
private sortItems<T extends { items?: ShoppingItem[] } | null>(list: T): T {
|
||||
if (!list || !list.items) return list;
|
||||
list.items.sort((a: any, b: any) => {
|
||||
list.items.sort((a: ShoppingItem, b: ShoppingItem) => {
|
||||
// Unchecked first
|
||||
if (a.checked !== b.checked) return a.checked ? 1 : -1;
|
||||
// Then by category
|
||||
|
|
@ -21,94 +126,4 @@ export class ShoppingListsRepository {
|
|||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
public async create(data: any) {
|
||||
const list = new ShoppingListModel(data);
|
||||
const saved = await list.save();
|
||||
return this.sortItems(saved.toObject());
|
||||
}
|
||||
|
||||
public async list(householdId: string) {
|
||||
const lists = await ShoppingListModel.find({ householdId })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return lists.map((l) => this.sortItems(l));
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
const list = await ShoppingListModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
return this.sortItems(list);
|
||||
}
|
||||
|
||||
public async findActiveByHousehold(householdId: string) {
|
||||
const lists = await ShoppingListModel.find({
|
||||
householdId,
|
||||
status: { $in: ['active', 'shopping'] },
|
||||
})
|
||||
.sort({ updatedAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return lists.map((l) => this.sortItems(l));
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateShoppingListInput) {
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: data },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
return this.sortItems(updated);
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
return ShoppingListModel.findOneAndDelete({ _id: id, householdId }).lean().exec();
|
||||
}
|
||||
|
||||
// --- Granular Atomic Subdocument Actions ---
|
||||
|
||||
public async addItem(id: string, householdId: string, item: ShoppingItem) {
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $push: { items: item } },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
return this.sortItems(updated);
|
||||
}
|
||||
|
||||
public async updateItem(
|
||||
id: string,
|
||||
householdId: string,
|
||||
itemId: string,
|
||||
updates: Partial<ShoppingItem>,
|
||||
) {
|
||||
const setUpdates: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(updates)) {
|
||||
setUpdates[`items.$.${key}`] = val;
|
||||
}
|
||||
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId, 'items.id': itemId },
|
||||
{ $set: setUpdates },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
return this.sortItems(updated);
|
||||
}
|
||||
|
||||
public async removeItem(id: string, householdId: string, itemId: string) {
|
||||
const updated = await ShoppingListModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $pull: { items: { id: itemId } } },
|
||||
{ new: true },
|
||||
)
|
||||
.lean()
|
||||
.exec();
|
||||
return this.sortItems(updated);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,21 @@ import {
|
|||
UpdateShoppingItemSchema,
|
||||
ShoppingListResponseSchema,
|
||||
type ShoppingItem,
|
||||
type CreateShoppingListInput,
|
||||
type UpdateShoppingListInput,
|
||||
type AddShoppingItemInput,
|
||||
type UpdateShoppingItemInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { ShoppingListsRepository } from './shopping-lists.repository.js';
|
||||
import { ShoppingListsService } from './shopping-lists.service.js';
|
||||
import type { ShoppingListDocument } from '../../schemas/shopping-list.schema.js';
|
||||
import { StoresRepository } from '../stores/stores.repository.js';
|
||||
|
||||
// Memory track for live concurrent websocket clients per active list session
|
||||
const activeListSockets = new Map<string, Set<WebSocket>>();
|
||||
|
||||
/* v8 ignore start */
|
||||
function broadcastToList(listId: string, excludeSocket: WebSocket, message: any) {
|
||||
function broadcastToList(listId: string, excludeSocket: WebSocket | null, message: unknown): void {
|
||||
const set = activeListSockets.get(listId);
|
||||
if (!set) return;
|
||||
const payload = JSON.stringify(message);
|
||||
|
|
@ -38,16 +43,49 @@ declare module '@fastify/awilix' {
|
|||
}
|
||||
}
|
||||
|
||||
function serializeList(doc: any) {
|
||||
interface SerializedShoppingList {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdBy: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
completedAt?: string;
|
||||
mealPlanId?: string;
|
||||
totalEstimatedCost?: number;
|
||||
preferredStoreId?: string;
|
||||
items: Array<Omit<ShoppingItem, 'checkedAt'> & { checkedAt?: string }>;
|
||||
}
|
||||
|
||||
function serializeList(doc: ShoppingListDocument): SerializedShoppingList {
|
||||
return {
|
||||
...doc,
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
name: doc.name,
|
||||
status: doc.status,
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: doc.createdAt?.toISOString(),
|
||||
updatedAt: doc.updatedAt?.toISOString(),
|
||||
completedAt: doc.completedAt?.toISOString(),
|
||||
items: doc.items.map((it: any) => ({
|
||||
...it,
|
||||
mealPlanId: doc.mealPlanId,
|
||||
totalEstimatedCost: doc.totalEstimatedCost,
|
||||
preferredStoreId: doc.preferredStoreId,
|
||||
items: doc.items.map((it: ShoppingItem) => ({
|
||||
id: it.id,
|
||||
productId: it.productId,
|
||||
customName: it.customName,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
checked: it.checked,
|
||||
checkedAt: it.checkedAt?.toISOString(),
|
||||
checkedBy: it.checkedBy,
|
||||
estimatedPrice: it.estimatedPrice,
|
||||
actualPrice: it.actualPrice,
|
||||
storeId: it.storeId,
|
||||
notes: it.notes,
|
||||
category: it.category,
|
||||
addedToPantry: it.addedToPantry,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -76,7 +114,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('shoppingListsService');
|
||||
const lists = await service.list(request.params.householdId);
|
||||
const params = request.params as { householdId: string };
|
||||
const lists = await service.list(params.householdId);
|
||||
return reply.send(lists.map(serializeList));
|
||||
},
|
||||
});
|
||||
|
|
@ -91,11 +130,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('shoppingListsService');
|
||||
const list = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as CreateShoppingListInput;
|
||||
const list = await service.create(body, params.householdId, request.user.keycloakId);
|
||||
return reply.status(201).send(serializeList(list));
|
||||
},
|
||||
});
|
||||
|
|
@ -109,7 +146,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('shoppingListsService');
|
||||
const list = await service.getById(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const list = await service.getById(params.id, params.householdId);
|
||||
return reply.send(serializeList(list));
|
||||
},
|
||||
});
|
||||
|
|
@ -124,11 +162,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('shoppingListsService');
|
||||
const list = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as UpdateShoppingListInput;
|
||||
const list = await service.update(params.id, params.householdId, body);
|
||||
return reply.send(serializeList(list));
|
||||
},
|
||||
});
|
||||
|
|
@ -141,7 +177,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('shoppingListsService');
|
||||
await service.delete(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
await service.delete(params.id, params.householdId);
|
||||
return reply.status(204).send();
|
||||
},
|
||||
});
|
||||
|
|
@ -158,14 +195,12 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('shoppingListsService');
|
||||
const { list, addedItem } = await service.addItem(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as AddShoppingItemInput;
|
||||
const { list, addedItem } = await service.addItem(params.id, params.householdId, body);
|
||||
|
||||
// Emit real-time update notification to existing connected viewers
|
||||
broadcastToList(request.params.id, null as any, {
|
||||
broadcastToList(params.id, null, {
|
||||
type: 'ITEM_ADDED',
|
||||
item: { ...addedItem, checkedAt: undefined },
|
||||
});
|
||||
|
|
@ -184,24 +219,24 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('shoppingListsService');
|
||||
const params = request.params as { householdId: string; id: string; itemId: string };
|
||||
const body = request.body as UpdateShoppingItemInput;
|
||||
const updatedList = await service.updateItem(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.params.itemId,
|
||||
request.body,
|
||||
params.id,
|
||||
params.householdId,
|
||||
params.itemId,
|
||||
body,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
|
||||
// Broadcast the precise item differential state update to sibling websocket listeners
|
||||
const matchedItem = updatedList.items.find(
|
||||
(i: ShoppingItem) => i.id === request.params.itemId,
|
||||
);
|
||||
const matchedItem = updatedList.items.find((i: ShoppingItem) => i.id === params.itemId);
|
||||
if (matchedItem) {
|
||||
broadcastToList(request.params.id, null as any, {
|
||||
broadcastToList(params.id, null, {
|
||||
type: 'ITEM_UPDATED',
|
||||
itemId: request.params.itemId,
|
||||
itemId: params.itemId,
|
||||
updates: {
|
||||
...request.body,
|
||||
...body,
|
||||
checkedAt: matchedItem.checkedAt?.toISOString(),
|
||||
checkedBy: matchedItem.checkedBy,
|
||||
},
|
||||
|
|
@ -221,15 +256,12 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('shoppingListsService');
|
||||
const list = await service.removeItem(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.params.itemId,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string; itemId: string };
|
||||
const list = await service.removeItem(params.id, params.householdId, params.itemId);
|
||||
|
||||
broadcastToList(request.params.id, null as any, {
|
||||
broadcastToList(params.id, null, {
|
||||
type: 'ITEM_REMOVED',
|
||||
itemId: request.params.itemId,
|
||||
itemId: params.itemId,
|
||||
});
|
||||
|
||||
return reply.send(serializeList(list));
|
||||
|
|
@ -239,6 +271,7 @@ export default fp(
|
|||
// 4. Persist Collaborative WebSocket Handshakes
|
||||
|
||||
/* v8 ignore start */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
|
||||
app.get(
|
||||
'/api/v1/households/:householdId/shopping-lists/:id/sync',
|
||||
{ websocket: true },
|
||||
|
|
@ -301,6 +334,7 @@ export default fp(
|
|||
});
|
||||
},
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */
|
||||
/* v8 ignore stop */
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ShoppingListsRepository } from './shopping-lists.repository.js';
|
||||
import type { ShoppingListDocument } from '../../schemas/shopping-list.schema.js';
|
||||
import type {
|
||||
CreateShoppingListInput,
|
||||
UpdateShoppingListInput,
|
||||
|
|
@ -6,8 +7,7 @@ import type {
|
|||
UpdateShoppingItemInput,
|
||||
ShoppingItem,
|
||||
} from '@meshitrack/shared';
|
||||
import { ShoppingListSourceType } from '@meshitrack/shared';
|
||||
import { StorageLocation, type ServingUnit } from '@meshitrack/shared';
|
||||
import { type ProductCategory } from '@meshitrack/shared';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
|
|
@ -18,29 +18,31 @@ interface Deps {
|
|||
export class ShoppingListsService {
|
||||
private readonly shoppingListsRepository: ShoppingListsRepository;
|
||||
|
||||
public constructor({
|
||||
shoppingListsRepository,
|
||||
}: Deps) {
|
||||
public constructor({ shoppingListsRepository }: Deps) {
|
||||
this.shoppingListsRepository = shoppingListsRepository;
|
||||
}
|
||||
|
||||
public async list(householdId: string) {
|
||||
public async list(householdId: string): Promise<ShoppingListDocument[]> {
|
||||
return this.shoppingListsRepository.list(householdId);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
public async getById(id: string, householdId: string): Promise<ShoppingListDocument> {
|
||||
const list = await this.shoppingListsRepository.findById(id, householdId);
|
||||
if (!list) throw new NotFoundError('Shopping list not found');
|
||||
return list;
|
||||
}
|
||||
|
||||
public async create(data: CreateShoppingListInput, householdId: string, userId: string) {
|
||||
public async create(
|
||||
data: CreateShoppingListInput,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<ShoppingListDocument> {
|
||||
const hydratedItems: ShoppingItem[] = [];
|
||||
|
||||
for (const it of data.items || []) {
|
||||
const itemId = uuidv4();
|
||||
let estimatedPrice: number | undefined;
|
||||
let category: string | undefined = it.category;
|
||||
const estimatedPrice: number | undefined = undefined;
|
||||
const category: string | undefined = it.category;
|
||||
|
||||
hydratedItems.push({
|
||||
id: itemId,
|
||||
|
|
@ -52,7 +54,7 @@ export class ShoppingListsService {
|
|||
addedToPantry: false,
|
||||
notes: it.notes,
|
||||
estimatedPrice,
|
||||
category: category as any,
|
||||
category: category as ProductCategory,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -65,25 +67,33 @@ export class ShoppingListsService {
|
|||
});
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateShoppingListInput) {
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateShoppingListInput,
|
||||
): Promise<ShoppingListDocument> {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.shoppingListsRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Shopping list not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async delete(id: string, householdId: string) {
|
||||
public async delete(id: string, householdId: string): Promise<ShoppingListDocument | null> {
|
||||
await this.getById(id, householdId);
|
||||
return this.shoppingListsRepository.delete(id, householdId);
|
||||
}
|
||||
|
||||
// --- Live Item Actions ---
|
||||
|
||||
public async addItem(id: string, householdId: string, data: AddShoppingItemInput) {
|
||||
public async addItem(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: AddShoppingItemInput,
|
||||
): Promise<{ list: ShoppingListDocument; addedItem: ShoppingItem }> {
|
||||
await this.getById(id, householdId);
|
||||
|
||||
let estimatedPrice: number | undefined;
|
||||
let category: string | undefined = data.category;
|
||||
const estimatedPrice: number | undefined = undefined;
|
||||
const category: string | undefined = data.category;
|
||||
|
||||
const newItem: ShoppingItem = {
|
||||
id: uuidv4(),
|
||||
|
|
@ -95,7 +105,7 @@ export class ShoppingListsService {
|
|||
addedToPantry: false,
|
||||
notes: data.notes,
|
||||
estimatedPrice,
|
||||
category: category as any,
|
||||
category: category as ProductCategory,
|
||||
};
|
||||
|
||||
const updated = await this.shoppingListsRepository.addItem(id, householdId, newItem);
|
||||
|
|
@ -109,7 +119,7 @@ export class ShoppingListsService {
|
|||
itemId: string,
|
||||
data: UpdateShoppingItemInput,
|
||||
userId: string,
|
||||
) {
|
||||
): Promise<ShoppingListDocument> {
|
||||
const updates: Partial<ShoppingItem> = { ...data };
|
||||
|
||||
if (data.checked !== undefined) {
|
||||
|
|
@ -122,10 +132,13 @@ export class ShoppingListsService {
|
|||
return updated;
|
||||
}
|
||||
|
||||
public async removeItem(id: string, householdId: string, itemId: string) {
|
||||
public async removeItem(
|
||||
id: string,
|
||||
householdId: string,
|
||||
itemId: string,
|
||||
): Promise<ShoppingListDocument> {
|
||||
const updated = await this.shoppingListsRepository.removeItem(id, householdId, itemId);
|
||||
if (!updated) throw new NotFoundError('Shopping list not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { StoreModel } from '../../schemas/store.schema.js';
|
||||
import type { StoreDocument } from '../../schemas/store.schema.js';
|
||||
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
|
||||
|
||||
export class StoresRepository {
|
||||
public async findByHousehold(householdId: string, query: StoreQueryInput) {
|
||||
public async findByHousehold(
|
||||
householdId: string,
|
||||
query: StoreQueryInput,
|
||||
): Promise<{ data: StoreDocument[]; pagination: { cursor: string | null; hasMore: boolean } }> {
|
||||
const filter: Record<string, unknown> = { householdId };
|
||||
|
||||
if (query.tags) {
|
||||
|
|
@ -34,32 +38,46 @@ export class StoresRepository {
|
|||
const cursor =
|
||||
data.length > 0 ? Buffer.from(data[data.length - 1]._id.toString()).toString('base64') : null;
|
||||
|
||||
return { data, pagination: { cursor: hasMore ? cursor : null, hasMore } };
|
||||
return {
|
||||
data: data as unknown as StoreDocument[],
|
||||
pagination: { cursor: hasMore ? cursor : null, hasMore },
|
||||
};
|
||||
}
|
||||
|
||||
public async findById(id: string, householdId: string) {
|
||||
return StoreModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
public async findById(id: string, householdId: string): Promise<StoreDocument | null> {
|
||||
const doc = await StoreModel.findOne({ _id: id, householdId }).lean().exec();
|
||||
return doc as unknown as StoreDocument | null;
|
||||
}
|
||||
|
||||
public async create(data: CreateStoreInput, householdId: string, createdBy: string) {
|
||||
public async create(
|
||||
data: CreateStoreInput,
|
||||
householdId: string,
|
||||
createdBy: string,
|
||||
): Promise<StoreDocument> {
|
||||
const store = new StoreModel({ ...data, householdId, createdBy });
|
||||
const saved = await store.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as StoreDocument;
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateStoreInput) {
|
||||
return StoreModel.findOneAndUpdate(
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateStoreInput,
|
||||
): Promise<StoreDocument | null> {
|
||||
const doc = await StoreModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as StoreDocument | null;
|
||||
}
|
||||
|
||||
public async deactivate(id: string, householdId: string) {
|
||||
return StoreModel.findOneAndUpdate(
|
||||
public async deactivate(id: string, householdId: string): Promise<StoreDocument | null> {
|
||||
const doc = await StoreModel.findOneAndUpdate(
|
||||
{ _id: id, householdId },
|
||||
{ $set: { isActive: false } },
|
||||
{ new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as StoreDocument | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@ import {
|
|||
StoreQuerySchema,
|
||||
StoreResponseSchema,
|
||||
StoreListResponseSchema,
|
||||
type CreateStoreInput,
|
||||
type UpdateStoreInput,
|
||||
type StoreQueryInput,
|
||||
} from '@meshitrack/shared';
|
||||
import { StoresRepository } from './stores.repository.js';
|
||||
import { StoresService } from './stores.service.js';
|
||||
import type { StoreDocument } from '../../schemas/store.schema.js';
|
||||
|
||||
type AnyStoreDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedStoreResponse {
|
||||
_id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
|
|
@ -23,31 +27,47 @@ type AnyStoreDoc = {
|
|||
tags: string[];
|
||||
isActive: boolean;
|
||||
createdBy: string;
|
||||
createdAt: string | Date | { toISOString: () => string };
|
||||
updatedAt: string | Date | { toISOString: () => string };
|
||||
};
|
||||
|
||||
function toIso(v: string | Date | { toISOString: () => string }): string {
|
||||
if (typeof v === 'string') return v;
|
||||
return v.toISOString();
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toStoreResponse(rawDoc: unknown) {
|
||||
const doc = rawDoc as AnyStoreDoc;
|
||||
return {
|
||||
_id: typeof doc._id === 'string' ? doc._id : doc._id.toString(),
|
||||
function toStoreResponse(doc: StoreDocument): SerializedStoreResponse {
|
||||
const docAny = doc as unknown as {
|
||||
address?: string | null;
|
||||
location?: { lat: number; lng: number } | null;
|
||||
url?: string | null;
|
||||
notes?: string | null;
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
};
|
||||
|
||||
const createdAtStr =
|
||||
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
|
||||
? docAny.createdAt.toISOString()
|
||||
: String(docAny.createdAt || '');
|
||||
|
||||
const updatedAtStr =
|
||||
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
|
||||
? docAny.updatedAt.toISOString()
|
||||
: String(docAny.updatedAt || '');
|
||||
|
||||
const response: SerializedStoreResponse = {
|
||||
_id: doc._id.toString(),
|
||||
householdId: doc.householdId,
|
||||
name: doc.name,
|
||||
...(doc.address != null ? { address: doc.address } : {}),
|
||||
...(doc.location != null ? { location: doc.location } : {}),
|
||||
...(doc.url != null ? { url: doc.url } : {}),
|
||||
...(doc.notes != null ? { notes: doc.notes } : {}),
|
||||
tags: doc.tags,
|
||||
tags: doc.tags || [],
|
||||
isActive: doc.isActive,
|
||||
createdBy: doc.createdBy,
|
||||
createdAt: toIso(doc.createdAt),
|
||||
updatedAt: toIso(doc.updatedAt),
|
||||
createdAt: createdAtStr,
|
||||
updatedAt: updatedAtStr,
|
||||
};
|
||||
|
||||
if (docAny.address) response.address = docAny.address;
|
||||
if (docAny.location) response.location = docAny.location;
|
||||
if (docAny.url) response.url = docAny.url;
|
||||
if (docAny.notes) response.notes = docAny.notes;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
declare module '@fastify/awilix' {
|
||||
|
|
@ -77,7 +97,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const result = await service.list(request.params.householdId, request.query);
|
||||
const params = request.params as { householdId: string };
|
||||
const query = request.query as StoreQueryInput;
|
||||
const result = await service.list(params.householdId, query);
|
||||
return reply.send({
|
||||
data: result.data.map(toStoreResponse),
|
||||
pagination: result.pagination,
|
||||
|
|
@ -94,7 +116,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const store = await service.getById(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const store = await service.getById(params.id, params.householdId);
|
||||
return reply.send(toStoreResponse(store));
|
||||
},
|
||||
});
|
||||
|
|
@ -109,11 +132,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const store = await service.create(
|
||||
request.body,
|
||||
request.params.householdId,
|
||||
request.user.keycloakId,
|
||||
);
|
||||
const params = request.params as { householdId: string };
|
||||
const body = request.body as CreateStoreInput;
|
||||
const store = await service.create(body, params.householdId, request.user.keycloakId);
|
||||
return reply.status(201).send(toStoreResponse(store));
|
||||
},
|
||||
});
|
||||
|
|
@ -128,11 +149,9 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const store = await service.update(
|
||||
request.params.id,
|
||||
request.params.householdId,
|
||||
request.body,
|
||||
);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const body = request.body as UpdateStoreInput;
|
||||
const store = await service.update(params.id, params.householdId, body);
|
||||
return reply.send(toStoreResponse(store));
|
||||
},
|
||||
});
|
||||
|
|
@ -146,7 +165,8 @@ export default fp(
|
|||
},
|
||||
handler: async (request, reply) => {
|
||||
const service = fastify.diContainer.resolve('storesService');
|
||||
const store = await service.deactivate(request.params.id, request.params.householdId);
|
||||
const params = request.params as { householdId: string; id: string };
|
||||
const store = await service.deactivate(params.id, params.householdId);
|
||||
return reply.send(toStoreResponse(store));
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { StoresRepository } from './stores.repository.js';
|
||||
import type { StoreDocument } from '../../schemas/store.schema.js';
|
||||
import type { CreateStoreInput, UpdateStoreInput, StoreQueryInput } from '@meshitrack/shared';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
|
|
@ -13,28 +14,39 @@ export class StoresService {
|
|||
this.storesRepository = storesRepository;
|
||||
}
|
||||
|
||||
public async list(householdId: string, query: StoreQueryInput) {
|
||||
public async list(
|
||||
householdId: string,
|
||||
query: StoreQueryInput,
|
||||
): Promise<{ data: StoreDocument[]; pagination: { cursor: string | null; hasMore: boolean } }> {
|
||||
return this.storesRepository.findByHousehold(householdId, query);
|
||||
}
|
||||
|
||||
public async getById(id: string, householdId: string) {
|
||||
public async getById(id: string, householdId: string): Promise<StoreDocument> {
|
||||
const store = await this.storesRepository.findById(id, householdId);
|
||||
if (!store) throw new NotFoundError('Store not found');
|
||||
return store;
|
||||
}
|
||||
|
||||
public async create(data: CreateStoreInput, householdId: string, userId: string) {
|
||||
public async create(
|
||||
data: CreateStoreInput,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<StoreDocument> {
|
||||
return this.storesRepository.create(data, householdId, userId);
|
||||
}
|
||||
|
||||
public async update(id: string, householdId: string, data: UpdateStoreInput) {
|
||||
public async update(
|
||||
id: string,
|
||||
householdId: string,
|
||||
data: UpdateStoreInput,
|
||||
): Promise<StoreDocument> {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.storesRepository.update(id, householdId, data);
|
||||
if (!updated) throw new NotFoundError('Store not found');
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async deactivate(id: string, householdId: string) {
|
||||
public async deactivate(id: string, householdId: string): Promise<StoreDocument> {
|
||||
await this.getById(id, householdId);
|
||||
const updated = await this.storesRepository.deactivate(id, householdId);
|
||||
if (!updated) throw new NotFoundError('Store not found');
|
||||
|
|
|
|||
|
|
@ -1,32 +1,47 @@
|
|||
import type mongoose from 'mongoose';
|
||||
import { UserModel } from '../../schemas/user.schema.js';
|
||||
import type { UserDocument } from '../../schemas/user.schema.js';
|
||||
import type { CreateUserInput, UpdateUserInput } from '@meshitrack/shared';
|
||||
|
||||
export class UsersRepository {
|
||||
public async findByKeycloakId(keycloakId: string, session?: mongoose.ClientSession) {
|
||||
return UserModel.findOne({ keycloakId }, null, { session }).lean().exec();
|
||||
public async findByKeycloakId(
|
||||
keycloakId: string,
|
||||
session?: mongoose.ClientSession,
|
||||
): Promise<UserDocument | null> {
|
||||
const doc = await UserModel.findOne({ keycloakId }, null, { session }).lean().exec();
|
||||
return doc as unknown as UserDocument | null;
|
||||
}
|
||||
|
||||
public async findById(id: string) {
|
||||
return UserModel.findById(id).lean().exec();
|
||||
public async findById(id: string): Promise<UserDocument | null> {
|
||||
const doc = await UserModel.findById(id).lean().exec();
|
||||
return doc as unknown as UserDocument | null;
|
||||
}
|
||||
|
||||
public async create(data: CreateUserInput) {
|
||||
public async create(data: CreateUserInput): Promise<UserDocument> {
|
||||
const user = new UserModel(data);
|
||||
const saved = await user.save();
|
||||
return saved.toObject();
|
||||
return saved.toObject() as UserDocument;
|
||||
}
|
||||
|
||||
public async update(keycloakId: string, data: UpdateUserInput, session?: mongoose.ClientSession) {
|
||||
return UserModel.findOneAndUpdate(
|
||||
public async update(
|
||||
keycloakId: string,
|
||||
data: UpdateUserInput,
|
||||
session?: mongoose.ClientSession,
|
||||
): Promise<UserDocument | null> {
|
||||
const doc = await UserModel.findOneAndUpdate(
|
||||
{ keycloakId },
|
||||
{ $set: data },
|
||||
{ new: true, lean: true, session },
|
||||
).exec();
|
||||
return doc as unknown as UserDocument | null;
|
||||
}
|
||||
|
||||
public async upsertFromToken(keycloakId: string, email: string, displayName: string) {
|
||||
return UserModel.findOneAndUpdate(
|
||||
public async upsertFromToken(
|
||||
keycloakId: string,
|
||||
email: string,
|
||||
displayName: string,
|
||||
): Promise<UserDocument | null> {
|
||||
const doc = await UserModel.findOneAndUpdate(
|
||||
{ keycloakId },
|
||||
{
|
||||
$set: { email, displayName },
|
||||
|
|
@ -34,5 +49,6 @@ export class UsersRepository {
|
|||
},
|
||||
{ upsert: true, new: true, lean: true },
|
||||
).exec();
|
||||
return doc as unknown as UserDocument | null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,31 +5,43 @@ import { UserResponseSchema } from '@meshitrack/shared';
|
|||
import { UsersRepository } from './users.repository.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
import type { UserDocument } from '../../schemas/user.schema.js';
|
||||
|
||||
type AnyUserDoc = {
|
||||
_id: string | { toString: () => string };
|
||||
interface SerializedUserResponse {
|
||||
_id: string;
|
||||
keycloakId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
householdIds: string[];
|
||||
defaultHouseholdId?: string | null;
|
||||
createdAt: string | { toISOString: () => string };
|
||||
updatedAt: string | { toISOString: () => string };
|
||||
};
|
||||
defaultHouseholdId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toUserResponse(doc: UserDocument): SerializedUserResponse {
|
||||
const docAny = doc as unknown as {
|
||||
createdAt?: { toISOString?: () => string } | string;
|
||||
updatedAt?: { toISOString?: () => string } | string;
|
||||
};
|
||||
|
||||
const createdAtStr =
|
||||
typeof docAny.createdAt === 'object' && typeof docAny.createdAt?.toISOString === 'function'
|
||||
? docAny.createdAt.toISOString()
|
||||
: String(docAny.createdAt || '');
|
||||
const updatedAtStr =
|
||||
typeof docAny.updatedAt === 'object' && typeof docAny.updatedAt?.toISOString === 'function'
|
||||
? docAny.updatedAt.toISOString()
|
||||
: String(docAny.updatedAt || '');
|
||||
|
||||
function toUserResponse(doc: AnyUserDoc) {
|
||||
const id = typeof doc._id === 'string' ? doc._id : doc._id.toString();
|
||||
const createdAt = typeof doc.createdAt === 'string' ? doc.createdAt : doc.createdAt.toISOString();
|
||||
const updatedAt = typeof doc.updatedAt === 'string' ? doc.updatedAt : doc.updatedAt.toISOString();
|
||||
return {
|
||||
_id: id,
|
||||
_id: doc._id.toString(),
|
||||
keycloakId: doc.keycloakId,
|
||||
displayName: doc.displayName,
|
||||
email: doc.email,
|
||||
householdIds: doc.householdIds,
|
||||
defaultHouseholdId: doc.defaultHouseholdId ?? null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
createdAt: createdAtStr,
|
||||
updatedAt: updatedAtStr,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { UsersRepository } from './users.repository.js';
|
||||
import type { UserDocument } from '../../schemas/user.schema.js';
|
||||
import type { AuthUser } from '../../common/types.js';
|
||||
import { NotFoundError } from '../../common/errors.js';
|
||||
|
||||
|
|
@ -13,11 +14,11 @@ export class UsersService {
|
|||
this.usersRepository = usersRepository;
|
||||
}
|
||||
|
||||
public async syncFromToken(user: AuthUser) {
|
||||
public async syncFromToken(user: AuthUser): Promise<UserDocument | null> {
|
||||
return this.usersRepository.upsertFromToken(user.keycloakId, user.email, user.displayName);
|
||||
}
|
||||
|
||||
public async getProfile(keycloakId: string) {
|
||||
public async getProfile(keycloakId: string): Promise<UserDocument> {
|
||||
const user = await this.usersRepository.findByKeycloakId(keycloakId);
|
||||
if (!user) {
|
||||
throw new NotFoundError('User not found');
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* Migration: normalize legacy dosage unit values to current DosageUnit enum.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable */
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
// Fixed ID shared with the Keycloak test users' householdIds attribute.
|
||||
|
|
|
|||
|
|
@ -23,22 +23,22 @@ describe(RefillsService.name, () => {
|
|||
getLatestForMedicine: vi.fn(),
|
||||
compareStores: vi.fn(),
|
||||
};
|
||||
const mockPurchasesRepo = {
|
||||
getPendingMedicineStock: vi.fn(),
|
||||
const mockShoppingListsRepo = {
|
||||
findActiveByHousehold: vi.fn(),
|
||||
};
|
||||
|
||||
let service: RefillsService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([]);
|
||||
mockShoppingListsRepo.findActiveByHousehold.mockResolvedValue([]);
|
||||
service = new RefillsService({
|
||||
refillsRepository: mockRepo as never,
|
||||
regimensService: mockRegimensService as never,
|
||||
cabinetRepository: mockCabinetRepo as never,
|
||||
cabinetService: mockCabinetService as never,
|
||||
medicinePricesRepository: mockPricesRepo as never,
|
||||
purchasesRepository: mockPurchasesRepo as never,
|
||||
shoppingListsRepository: mockShoppingListsRepo as never,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ describe(RefillsService.name, () => {
|
|||
expect(result[0].cheapestOption?.storeName).toBe('CVS');
|
||||
});
|
||||
|
||||
it('includes pendingOrderStock and daysUntilEmptyWithOrders from ordered purchases', async () => {
|
||||
it('includes pendingOrderStock and daysUntilEmptyWithOrders from active shopping lists', async () => {
|
||||
mockRegimensService.calculateBurnRates.mockResolvedValue([
|
||||
{
|
||||
medicineId: 'med-1',
|
||||
|
|
@ -151,8 +151,12 @@ describe(RefillsService.name, () => {
|
|||
mockCabinetRepo.getAggregateSummary.mockResolvedValue([]);
|
||||
mockPricesRepo.getLatestForMedicine.mockResolvedValue(null);
|
||||
mockPricesRepo.compareStores.mockResolvedValue([]);
|
||||
mockPurchasesRepo.getPendingMedicineStock.mockResolvedValue([
|
||||
{ medicineId: 'med-1', totalUnits: 60 },
|
||||
mockShoppingListsRepo.findActiveByHousehold.mockResolvedValue([
|
||||
{
|
||||
items: [
|
||||
{ productId: 'med-1', quantity: 60, checked: false },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getAlerts('hh1', 'user-1', 7);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,102 @@ export default tseslint.config(
|
|||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/member-ordering': [
|
||||
'error',
|
||||
{
|
||||
default: [
|
||||
'public-static-field',
|
||||
'protected-static-field',
|
||||
'private-static-field',
|
||||
'public-instance-field',
|
||||
'protected-instance-field',
|
||||
'private-instance-field',
|
||||
'constructor',
|
||||
'public-instance-method',
|
||||
'protected-instance-method',
|
||||
'private-instance-method',
|
||||
],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'error',
|
||||
{
|
||||
allowExpressions: true,
|
||||
allowTypedFunctionExpressions: true,
|
||||
allowHigherOrderFunctions: true,
|
||||
allowDirectConstAssertionInArrowFunctions: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-unsafe-return': 'error',
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'default',
|
||||
format: ['camelCase'],
|
||||
leadingUnderscore: 'allow',
|
||||
trailingUnderscore: 'allow',
|
||||
},
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['camelCase', 'UPPER_CASE', 'PascalCase'],
|
||||
leadingUnderscore: 'allow',
|
||||
trailingUnderscore: 'allow',
|
||||
},
|
||||
{
|
||||
selector: 'typeLike',
|
||||
format: ['PascalCase'],
|
||||
},
|
||||
{
|
||||
selector: 'interface',
|
||||
format: ['PascalCase'],
|
||||
custom: {
|
||||
regex: '^I[A-Z]',
|
||||
match: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'objectLiteralProperty',
|
||||
format: null,
|
||||
},
|
||||
{
|
||||
selector: 'objectLiteralMethod',
|
||||
format: null,
|
||||
},
|
||||
{
|
||||
selector: 'function',
|
||||
format: ['camelCase', 'PascalCase'],
|
||||
},
|
||||
{
|
||||
selector: 'import',
|
||||
format: ['camelCase', 'PascalCase'],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
'error',
|
||||
{
|
||||
checksVoidReturn: {
|
||||
attributes: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// React components are functions returning JSX and don't need explicit return types
|
||||
files: ['**/*.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Relax some rules in test files
|
||||
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-member-accessibility': 'off',
|
||||
},
|
||||
},
|
||||
prettierRecommended,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
import { useApi } from '@/lib/useApi';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { Card, Button, Icon, Pill } from '@/components/ui';
|
||||
import type {
|
||||
BasketStoreComparisonResponse,
|
||||
ShoppingItem,
|
||||
ShoppingListResponse,
|
||||
} from '@/services/shopping-lists';
|
||||
import { type ServingUnit } from '@meshitrack/shared';
|
||||
import {
|
||||
getShoppingList,
|
||||
addShoppingItem,
|
||||
|
|
@ -15,6 +21,7 @@ import {
|
|||
getBasketStoreComparison,
|
||||
updateShoppingList,
|
||||
} from '@/services/shopping-lists';
|
||||
import type { ProductResponse } from '@/services/products';
|
||||
import { listProducts } from '@/services/products';
|
||||
import { useShoppingListSync } from '@/lib/useShoppingListSync';
|
||||
|
||||
|
|
@ -24,9 +31,11 @@ export default function ShoppingListDetailsPage() {
|
|||
const router = useRouter();
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [storeOptions, setStoreOptions] = useState<any[]>([]);
|
||||
const [isStoreLoading, setIsStoreLoading] = useState(false);
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
const [storeOptions, setStoreOptions] = useState<
|
||||
BasketStoreComparisonResponse['singleStoreOptions']
|
||||
>([]);
|
||||
const [_isStoreLoading, setIsStoreLoading] = useState(false);
|
||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||
const [selectedProductId, setSelectedProductId] = useState('');
|
||||
const [customItemName, setCustomItemName] = useState('');
|
||||
const [qty, setQty] = useState(1);
|
||||
|
|
@ -36,10 +45,12 @@ export default function ShoppingListDetailsPage() {
|
|||
|
||||
// Load core list context
|
||||
const swrKey = householdId && listId ? `shopping-list-${householdId}-${listId}` : null;
|
||||
const { data: list, mutate: mutateList, isLoading: listLoading, error: listError } = useSWR(
|
||||
swrKey,
|
||||
() => getShoppingList(householdId!, listId)
|
||||
);
|
||||
const {
|
||||
data: list,
|
||||
mutate: mutateList,
|
||||
isLoading: listLoading,
|
||||
error: listError,
|
||||
} = useSWR<ShoppingListResponse, Error>(swrKey, () => getShoppingList(householdId!, listId));
|
||||
|
||||
useEffect(() => {
|
||||
if (listError) setError(listError.message || 'Shopping list not found');
|
||||
|
|
@ -61,31 +72,42 @@ export default function ShoppingListDetailsPage() {
|
|||
// Pre-load household products for predictive inputs
|
||||
useEffect(() => {
|
||||
if (!householdId) return;
|
||||
listProducts(householdId).then(res => setProducts(res.data)).catch(console.error);
|
||||
listProducts(householdId)
|
||||
.then((res) => setProducts(res.data))
|
||||
.catch(console.error);
|
||||
}, [householdId]);
|
||||
|
||||
// Handle WS Remote Event Broadcasts
|
||||
const handleRemoteSync = useCallback((msg: any) => {
|
||||
console.log('🔔 Remote state delta payload:', msg);
|
||||
mutateList(); // Revalidate with server on remote changes
|
||||
}, [mutateList]);
|
||||
const handleRemoteSync = useCallback(
|
||||
(msg: unknown) => {
|
||||
console.log('🔔 Remote state delta payload:', msg);
|
||||
void mutateList(); // Revalidate with server on remote changes
|
||||
},
|
||||
[mutateList],
|
||||
);
|
||||
|
||||
// Inject Real-Time Hooks
|
||||
const { isConnected, toggleItemCheck } = useShoppingListSync(
|
||||
householdId || '',
|
||||
listId,
|
||||
handleRemoteSync
|
||||
handleRemoteSync,
|
||||
);
|
||||
|
||||
// 1. Perform Live Interactivity (Toggle Checks)
|
||||
const handleToggleCheck = async (itemId: string, currentChecked: boolean) => {
|
||||
const nextChecked = !currentChecked;
|
||||
|
||||
|
||||
// Optimistic Client Update
|
||||
mutateList(async (prev: any) => ({
|
||||
...prev,
|
||||
items: prev.items.map((it: any) => it.id === itemId ? { ...it, checked: nextChecked } : it)
|
||||
}), { revalidate: false });
|
||||
void mutateList(
|
||||
async (prev) => {
|
||||
if (!prev) return undefined;
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((it) => (it.id === itemId ? { ...it, checked: nextChecked } : it)),
|
||||
};
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
|
||||
// Emit to WS Channel
|
||||
toggleItemCheck(itemId, nextChecked);
|
||||
|
|
@ -93,10 +115,10 @@ export default function ShoppingListDetailsPage() {
|
|||
// Persist standard Rest fallback ensuring safety
|
||||
try {
|
||||
await updateShoppingItem(householdId!, listId, itemId, { checked: nextChecked });
|
||||
mutateList();
|
||||
void mutateList();
|
||||
} catch (err) {
|
||||
console.error('Persistent toggle sync fail', err);
|
||||
mutateList();
|
||||
void mutateList();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -109,18 +131,18 @@ export default function ShoppingListDetailsPage() {
|
|||
productId: selectedProductId || undefined,
|
||||
customName: !selectedProductId ? customItemName.trim() : undefined,
|
||||
quantity: qty,
|
||||
unit: unit as any,
|
||||
unit: unit as ServingUnit,
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
|
||||
mutateList(updated);
|
||||
|
||||
void mutateList(updated);
|
||||
// Clear inputs
|
||||
setSelectedProductId('');
|
||||
setCustomItemName('');
|
||||
setQty(1);
|
||||
setNotes('');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
|
|
@ -128,47 +150,60 @@ export default function ShoppingListDetailsPage() {
|
|||
|
||||
const handleDeleteItem = async (itemId: string) => {
|
||||
// Optimistic delete
|
||||
mutateList(async (prev: any) => ({
|
||||
...prev,
|
||||
items: prev.items.filter((it: any) => it.id !== itemId)
|
||||
}), { revalidate: false });
|
||||
void mutateList(
|
||||
async (prev) => {
|
||||
if (!prev) return undefined;
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.filter((it) => it.id !== itemId),
|
||||
};
|
||||
},
|
||||
{ revalidate: false },
|
||||
);
|
||||
|
||||
try {
|
||||
const updated = await removeShoppingItem(householdId!, listId, itemId);
|
||||
mutateList(updated);
|
||||
} catch (err: any) {
|
||||
window.alert(err.message);
|
||||
mutateList();
|
||||
void mutateList(updated);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
window.alert(message);
|
||||
void mutateList();
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Execute Final Checkout / Pantry Sync
|
||||
const handleSyncToPantry = async () => {
|
||||
const readyItems = list!.items.filter((i: any) => i.checked && !i.addedToPantry);
|
||||
const readyItems = list!.items.filter((i) => i.checked && !i.addedToPantry);
|
||||
|
||||
if (!window.confirm(`Import ${readyItems.length} checked ingredients directly into active Pantry stock?`)) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Import ${readyItems.length} checked ingredients directly into active Pantry stock?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const res = await syncToPantry(householdId!, listId);
|
||||
window.alert(`Success! Provisioned ${res.addedCount} items into Pantry stock.`);
|
||||
|
||||
|
||||
// Mark list as completed automatically if all are done
|
||||
const allChecked = list!.items.every((i: any) => i.checked || i.addedToPantry);
|
||||
const allChecked = list!.items.every((i) => i.checked || i.addedToPantry);
|
||||
if (allChecked) {
|
||||
await updateShoppingList(householdId!, listId, { status: 'completed' as any });
|
||||
await updateShoppingList(householdId!, listId, { status: 'completed' });
|
||||
}
|
||||
|
||||
mutateList();
|
||||
} catch (err: any) {
|
||||
window.alert('Migration sync error: ' + err.message);
|
||||
void mutateList();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
window.alert('Migration sync error: ' + message);
|
||||
}
|
||||
};
|
||||
|
||||
// Collate items categorized for satisfying view
|
||||
const categorizedItems = useMemo(() => {
|
||||
if (!list) return {};
|
||||
const groups: Record<string, any[]> = {};
|
||||
list.items.forEach((it: any) => {
|
||||
const groups: Record<string, ShoppingItem[]> = {};
|
||||
list.items.forEach((it) => {
|
||||
const cat = it.category || 'Other / Misc';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(it);
|
||||
|
|
@ -176,11 +211,12 @@ export default function ShoppingListDetailsPage() {
|
|||
return groups;
|
||||
}, [list]);
|
||||
|
||||
if (isAuthLoading || listLoading) return <div style={{ padding: 40 }}>Hydrating session checklist...</div>;
|
||||
if (error || !list) return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
|
||||
if (isAuthLoading || listLoading)
|
||||
return <div style={{ padding: 40 }}>Hydrating session checklist...</div>;
|
||||
if (error || !list)
|
||||
return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
|
||||
|
||||
const itemsPendingSync = list.items.filter((i: any) => i.checked && !i.addedToPantry).length;
|
||||
const checkedCount = list.items.filter((i: any) => i.checked).length;
|
||||
const itemsPendingSync = list.items.filter((i) => i.checked && !i.addedToPantry).length;
|
||||
const totalCount = list.items.length;
|
||||
|
||||
return (
|
||||
|
|
@ -190,64 +226,137 @@ export default function ShoppingListDetailsPage() {
|
|||
subtitle="Perform live checkout check-offs synchronously across multiple household devices."
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, fontSize: 13, padding: '0 32px', marginTop: -12, marginBottom: 12, maxWidth: 1400, margin: '-12px auto 12px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
fontSize: 13,
|
||||
padding: '0 32px',
|
||||
marginTop: -12,
|
||||
marginBottom: 12,
|
||||
maxWidth: 1400,
|
||||
margin: '-12px auto 12px',
|
||||
}}
|
||||
>
|
||||
<Pill tone={list.status === 'completed' ? 'ok' : 'info'}>{list.status}</Pill>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--ink-muted)' }}>
|
||||
<div style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: isConnected ? 'var(--success, #10b981)' : 'var(--danger, #ef4444)',
|
||||
boxShadow: isConnected ? '0 0 8px var(--success)' : 'none',
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: isConnected ? 'var(--success, #10b981)' : 'var(--danger, #ef4444)',
|
||||
boxShadow: isConnected ? '0 0 8px var(--success)' : 'none',
|
||||
}}
|
||||
/>
|
||||
{isConnected ? 'Live Sync Channel Operational' : 'Connecting Sync...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '28px 32px 64px', maxWidth: 1400, margin: '0 auto' }}>
|
||||
|
||||
{/* Top Action Strip */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 24, justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => router.push('/shopping-lists')}>
|
||||
<Icon name="chevronLeft" style={{ marginRight: 6, width: 16 }} /> Back to Hub
|
||||
</Button>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button variant="ghost" onClick={fetchComparisons}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
void fetchComparisons();
|
||||
}}
|
||||
>
|
||||
<Icon name="trend" style={{ marginRight: 6, width: 16 }} /> Check Lowest Store Options
|
||||
</Button>
|
||||
{itemsPendingSync > 0 && (
|
||||
<Button onClick={handleSyncToPantry} style={{ background: 'var(--success)', borderColor: 'var(--success)', color: '#fff' }}>
|
||||
<Icon name="box" style={{ marginRight: 6, width: 16 }} /> Sync {itemsPendingSync} items to Pantry
|
||||
<Button
|
||||
onClick={() => {
|
||||
void handleSyncToPantry();
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--success)',
|
||||
borderColor: 'var(--success)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<Icon name="box" style={{ marginRight: 6, width: 16 }} /> Sync {itemsPendingSync}{' '}
|
||||
items to Pantry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Workspace Split Grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 380px', gap: 32, alignItems: 'start' }}>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 380px',
|
||||
gap: 32,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* Left: Categorized Checklist Grid */}
|
||||
<div>
|
||||
{totalCount === 0 ? (
|
||||
<Card style={{ padding: 40, textAlign: 'center', background: 'var(--bg-elev)', border: '1px dashed var(--border)' }}>
|
||||
<Icon name="list" style={{ width: 40, color: 'var(--ink-muted)', marginBottom: 16 }} />
|
||||
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)' }}>Checklist is Empty</h4>
|
||||
<p style={{ fontSize: 13, color: 'var(--ink-muted)' }}>Add missing ingredients using the pane on the right.</p>
|
||||
<Card
|
||||
style={{
|
||||
padding: 40,
|
||||
textAlign: 'center',
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px dashed var(--border)',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name="list"
|
||||
style={{ width: 40, color: 'var(--ink-muted)', marginBottom: 16 }}
|
||||
/>
|
||||
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)' }}>
|
||||
Checklist is Empty
|
||||
</h4>
|
||||
<p style={{ fontSize: 13, color: 'var(--ink-muted)' }}>
|
||||
Add missing ingredients using the pane on the right.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{Object.entries(categorizedItems).map(([cat, items]: [string, any]) => (
|
||||
{Object.entries(categorizedItems).map(([cat, items]) => (
|
||||
<div key={cat}>
|
||||
<h4 style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 12, borderBottom: '1px solid var(--border)', paddingBottom: 6 }}>
|
||||
<h4
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: 12,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
paddingBottom: 6,
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{items.map((it: any) => (
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 16px', background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--r-md)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
transition: 'all 0.15s',
|
||||
opacity: it.checked ? 0.65 : 1,
|
||||
textDecoration: it.checked ? 'line-through' : 'none',
|
||||
|
|
@ -255,32 +364,70 @@ export default function ShoppingListDetailsPage() {
|
|||
>
|
||||
{/* Checkbox circle */}
|
||||
<button
|
||||
onClick={() => handleToggleCheck(it.id, it.checked)}
|
||||
aria-label={`Toggle check for ${it.productId ? products.find(p => p._id === it.productId)?.name : it.customName}`}
|
||||
onClick={() => {
|
||||
void handleToggleCheck(it.id, it.checked);
|
||||
}}
|
||||
aria-label={`Toggle check for ${it.productId ? products.find((p) => p._id === it.productId)?.name : it.customName}`}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: '50%',
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${it.checked ? 'var(--success, #10b981)' : 'var(--border-hover)'}`,
|
||||
background: it.checked ? 'var(--success, #10b981)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', flexShrink: 0, padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{it.checked && <Icon name="check" style={{ width: 12, color: '#fff' }} />}
|
||||
{it.checked && (
|
||||
<Icon name="check" style={{ width: 12, color: '#fff' }} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: it.checked ? 'var(--ink-muted)' : 'var(--ink)' }}>
|
||||
{it.productId ? products.find(p => p._id === it.productId)?.name || 'Ingredient Loading...' : it.customName}
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
color: it.checked ? 'var(--ink-muted)' : 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
{it.productId
|
||||
? products.find((p) => p._id === it.productId)?.name ||
|
||||
'Ingredient Loading...'
|
||||
: it.customName}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-muted)', display: 'flex', gap: 10, marginTop: 2 }}>
|
||||
<span>Qty: {it.quantity} {it.unit}</span>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--ink-muted)',
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Qty: {it.quantity} {it.unit}
|
||||
</span>
|
||||
{it.notes && <span>• Note: {it.notes}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estimated Price Tag */}
|
||||
{it.estimatedPrice && !it.checked && (
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-muted)', background: 'var(--bg)', padding: '4px 8px', borderRadius: 'var(--r-sm)' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink-muted)',
|
||||
background: 'var(--bg)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
}}
|
||||
>
|
||||
~${it.estimatedPrice.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -293,9 +440,18 @@ export default function ShoppingListDetailsPage() {
|
|||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteItem(it.id)}
|
||||
aria-label={`Delete ${it.productId ? products.find(p => p._id === it.productId)?.name : it.customName}`}
|
||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, color: 'var(--ink-muted)', opacity: 0.5 }}
|
||||
onClick={() => {
|
||||
void handleDeleteItem(it.id);
|
||||
}}
|
||||
aria-label={`Delete ${it.productId ? products.find((p) => p._id === it.productId)?.name : it.customName}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
padding: 6,
|
||||
color: 'var(--ink-muted)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" style={{ width: 14 }} />
|
||||
</button>
|
||||
|
|
@ -310,13 +466,26 @@ export default function ShoppingListDetailsPage() {
|
|||
|
||||
{/* Right Side Panel: Context Inputs */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
|
||||
{/* Pane A: Add New Item */}
|
||||
<Card style={{ padding: 20 }}>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h4
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" style={{ width: 16 }} /> Add Grocery Item
|
||||
</h4>
|
||||
<form onSubmit={handleAddItem} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
void handleAddItem(e);
|
||||
}}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 14 }}
|
||||
>
|
||||
<div>
|
||||
<label style={labelStyle}>Link Product Catalog</label>
|
||||
<select
|
||||
|
|
@ -328,7 +497,11 @@ export default function ShoppingListDetailsPage() {
|
|||
style={selectStyle}
|
||||
>
|
||||
<option value="">-- Create Manual Custom Input --</option>
|
||||
{products.map(p => <option key={p._id} value={p._id}>{p.name}</option>)}
|
||||
{products.map((p) => (
|
||||
<option key={p._id} value={p._id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -340,7 +513,7 @@ export default function ShoppingListDetailsPage() {
|
|||
required
|
||||
placeholder="e.g., Generic Flour"
|
||||
value={customItemName}
|
||||
onChange={e => setCustomItemName(e.target.value)}
|
||||
onChange={(e) => setCustomItemName(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -355,13 +528,17 @@ export default function ShoppingListDetailsPage() {
|
|||
min="0.01"
|
||||
step="any"
|
||||
value={qty}
|
||||
onChange={e => setQty(parseFloat(e.target.value) || 0)}
|
||||
onChange={(e) => setQty(parseFloat(e.target.value) || 0)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Unit</label>
|
||||
<select value={unit} onChange={e => setUnit(e.target.value)} style={selectStyle}>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="g">Grams</option>
|
||||
<option value="ml">Milliliters</option>
|
||||
<option value="piece">Pieces</option>
|
||||
|
|
@ -376,7 +553,7 @@ export default function ShoppingListDetailsPage() {
|
|||
type="text"
|
||||
placeholder="Brand preference, etc."
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -390,19 +567,67 @@ export default function ShoppingListDetailsPage() {
|
|||
{/* Pane B: Real-Time Store Optimizer */}
|
||||
{storeOptions.length > 0 && (
|
||||
<Card style={{ padding: 20 }}>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Icon name="trend" style={{ width: 16, color: 'var(--brand)' }} /> Lowest Store Basket Rank
|
||||
<h4
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Icon name="trend" style={{ width: 16, color: 'var(--brand)' }} /> Lowest Store
|
||||
Basket Rank
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{storeOptions.map((opt, idx) => (
|
||||
<div key={opt.storeId} style={{ padding: 12, background: 'var(--bg)', border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)', borderRadius: 'var(--r-md)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 13 }}>{opt.storeName}</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: idx === 0 ? 'var(--success)' : 'var(--ink)' }}>${opt.estimatedTotal.toFixed(2)}</span>
|
||||
<div
|
||||
key={opt.storeId}
|
||||
style={{
|
||||
padding: 12,
|
||||
background: 'var(--bg)',
|
||||
border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 13 }}>
|
||||
{opt.storeName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: idx === 0 ? 'var(--success)' : 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
${opt.estimatedTotal.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--ink-muted)', marginTop: 4 }}>
|
||||
<span>Covered: {opt.itemsCovered}/{totalCount} products</span>
|
||||
{idx === 0 && <span style={{ color: 'var(--success)', fontWeight: 600 }}>Cheapest Single Trip</span>}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 11,
|
||||
color: 'var(--ink-muted)',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Covered: {opt.itemsCovered}/{totalCount} products
|
||||
</span>
|
||||
{idx === 0 && (
|
||||
<span style={{ color: 'var(--success)', fontWeight: 600 }}>
|
||||
Cheapest Single Trip
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -417,18 +642,34 @@ export default function ShoppingListDetailsPage() {
|
|||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--ink-muted)',
|
||||
textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6,
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.03em',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)',
|
||||
fontSize: 13, outline: 'none',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '8px 12px', borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)',
|
||||
fontSize: 13, outline: 'none', height: 36,
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
height: 36,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,24 +4,35 @@ import { useState, useEffect, useCallback } from 'react';
|
|||
import { useApi } from '@/lib/useApi';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { Card, Button, Icon, Pill } from '@/components/ui';
|
||||
import type { ShoppingListResponse } from '@/services/shopping-lists';
|
||||
import { getShoppingLists, createShoppingList } from '@/services/shopping-lists';
|
||||
import type { MealPlanResponse } from '@/services/meal-plans';
|
||||
import { listMealPlans } from '@/services/meal-plans';
|
||||
import { generateFromMealPlan } from '@/services/shopping-lists';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
color: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export default function ShoppingListsPage() {
|
||||
const { householdId, isLoading } = useApi();
|
||||
const [lists, setLists] = useState<any[]>([]);
|
||||
const [lists, setLists] = useState<ShoppingListResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
|
||||
// Modal States
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isGapModalOpen, setIsGapModalOpen] = useState(false);
|
||||
|
||||
|
||||
// Form States
|
||||
const [newListName, setNewListName] = useState('');
|
||||
const [recentMealPlans, setRecentMealPlans] = useState<any[]>([]);
|
||||
const [recentMealPlans, setRecentMealPlans] = useState<MealPlanResponse[]>([]);
|
||||
const [mealPlanLoading, setMealPlanLoading] = useState(false);
|
||||
|
||||
const fetchLists = useCallback(async () => {
|
||||
|
|
@ -36,15 +47,16 @@ export default function ShoppingListsPage() {
|
|||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
setLists(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load shopping lists');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load shopping lists';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLists();
|
||||
void fetchLists();
|
||||
}, [fetchLists]);
|
||||
|
||||
const handleCreateList = async (e: React.FormEvent) => {
|
||||
|
|
@ -59,8 +71,9 @@ export default function ShoppingListsPage() {
|
|||
setIsCreateModalOpen(false);
|
||||
// Redirect or update list
|
||||
setLists((prev) => [res, ...prev]);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to create list');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create list';
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -82,28 +95,44 @@ export default function ShoppingListsPage() {
|
|||
const res = await generateFromMealPlan(householdId!, mealPlanId);
|
||||
setIsGapModalOpen(false);
|
||||
setLists((prev) => [res, ...prev]);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to generate groceries from meal plan');
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to generate groceries from meal plan';
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <SetPageHeader title="Groceries" subtitle="Analyze needs and track baskets" />;
|
||||
if (isLoading)
|
||||
return <SetPageHeader title="Groceries" subtitle="Analyze needs and track baskets" />;
|
||||
if (!householdId) return <div style={{ padding: 32 }}>Please join a household.</div>;
|
||||
|
||||
const activeLists = lists.filter(l => l.status === 'active' || l.status === 'shopping');
|
||||
const completedLists = lists.filter(l => l.status === 'completed' || l.status === 'archived');
|
||||
|
||||
const activeLists = lists.filter((l) => l.status === 'active' || l.status === 'shopping');
|
||||
const completedLists = lists.filter((l) => l.status === 'completed' || l.status === 'archived');
|
||||
|
||||
// Derive stats
|
||||
const totalActiveCost = activeLists.reduce((sum, l) => sum + (l.totalEstimatedCost || 0), 0);
|
||||
const totalPendingItems = activeLists.reduce((sum, l) => sum + l.items.filter((i: any) => !i.checked).length, 0);
|
||||
const totalPendingItems = activeLists.reduce(
|
||||
(sum, l) => sum + l.items.filter((i) => !i.checked).length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageHeader title="Grocery & Shopping" subtitle="Streamline your checklist, check gaps, and compare costs." />
|
||||
|
||||
<SetPageHeader
|
||||
title="Grocery & Shopping"
|
||||
subtitle="Streamline your checklist, check gaps, and compare costs."
|
||||
/>
|
||||
|
||||
<div style={{ padding: '28px 32px 64px', maxWidth: 1300, margin: '0 auto' }}>
|
||||
{/* 1. Beautiful Stats Band */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 20, marginBottom: 32 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||
gap: 20,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<MetricCard
|
||||
icon="store"
|
||||
title="Active Lists"
|
||||
|
|
@ -128,7 +157,7 @@ export default function ShoppingListsPage() {
|
|||
<MetricCard
|
||||
icon="trend"
|
||||
title="Spending Trend"
|
||||
value="Analyics"
|
||||
value="Analytics"
|
||||
subtitle="Visualize price fluctuations"
|
||||
color="var(--ink-muted)"
|
||||
link="/shopping-lists/prices"
|
||||
|
|
@ -136,10 +165,26 @@ export default function ShoppingListsPage() {
|
|||
</div>
|
||||
|
||||
{/* 2. Action Row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24, flexWrap: 'wrap', gap: 16 }}>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>Checklists & Baskets</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 600, color: 'var(--ink)' }}>
|
||||
Checklists & Baskets
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button variant="ghost" onClick={openGapModal}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
void openGapModal();
|
||||
}}
|
||||
>
|
||||
<Icon name="zap" style={{ marginRight: 6, width: 16 }} />
|
||||
Generate from Meal Plan
|
||||
</Button>
|
||||
|
|
@ -150,19 +195,70 @@ export default function ShoppingListsPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: 'var(--danger)', padding: 16, background: 'var(--danger-soft)', borderRadius: 'var(--r-md)', marginBottom: 24 }}>{error}</div>}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--danger)',
|
||||
padding: 16,
|
||||
background: 'var(--danger-soft)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Lists Grid */}
|
||||
{loading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20 }}>
|
||||
{[1, 2, 3].map(i => <div key={i} style={{ height: 180, borderRadius: 'var(--r-lg)', border: '1px solid var(--border)', background: 'var(--bg-elev)', opacity: 0.4 }} />)}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 180,
|
||||
borderRadius: 'var(--r-lg)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-elev)',
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : activeLists.length === 0 && completedLists.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '80px 24px', background: 'var(--bg-elev)', border: '1px dashed var(--border)', borderRadius: 'var(--r-lg)' }}>
|
||||
<Icon name="store" style={{ width: 48, height: 48, color: 'var(--ink-muted)', marginBottom: 16 }} />
|
||||
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>No Shopping Lists Found</h4>
|
||||
<p style={{ color: 'var(--ink-muted)', fontSize: 14, marginBottom: 24, maxWidth: 400, margin: '0 auto 24px' }}>
|
||||
Create an empty manual checklist, or dynamically auto-generate missing ingredients directly from your meal plan!
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '80px 24px',
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name="store"
|
||||
style={{ width: 48, height: 48, color: 'var(--ink-muted)', marginBottom: 16 }}
|
||||
/>
|
||||
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>
|
||||
No Shopping Lists Found
|
||||
</h4>
|
||||
<p
|
||||
style={{
|
||||
color: 'var(--ink-muted)',
|
||||
fontSize: 14,
|
||||
marginBottom: 24,
|
||||
maxWidth: 400,
|
||||
margin: '0 auto 24px',
|
||||
}}
|
||||
>
|
||||
Create an empty manual checklist, or dynamically auto-generate missing ingredients
|
||||
directly from your meal plan!
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateModalOpen(true)}>Create First List</Button>
|
||||
</div>
|
||||
|
|
@ -170,7 +266,14 @@ export default function ShoppingListsPage() {
|
|||
<>
|
||||
{/* Active Section */}
|
||||
{activeLists.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20, marginBottom: 40 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||
gap: 20,
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
{activeLists.map((list) => (
|
||||
<ShoppingListCard key={list._id} list={list} />
|
||||
))}
|
||||
|
|
@ -180,8 +283,25 @@ export default function ShoppingListsPage() {
|
|||
{/* Past Section */}
|
||||
{completedLists.length > 0 && (
|
||||
<>
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 16 }}>Completed Runs</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 20 }}>
|
||||
<h4
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
Completed Runs
|
||||
</h4>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{completedLists.map((list) => (
|
||||
<ShoppingListCard key={list._id} list={list} />
|
||||
))}
|
||||
|
|
@ -196,10 +316,26 @@ export default function ShoppingListsPage() {
|
|||
{isCreateModalOpen && (
|
||||
<div style={overlayStyle} onClick={() => setIsCreateModalOpen(false)}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 16 }}>Create Shopping List</h3>
|
||||
<form onSubmit={handleCreateList}>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||
Create Shopping List
|
||||
</h3>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
void handleCreateList(e);
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--ink-muted)', marginBottom: 6 }}>Checklist Name</label>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'var(--ink-muted)',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
Checklist Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
|
|
@ -211,7 +347,9 @@ export default function ShoppingListsPage() {
|
|||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||
<Button type="button" variant="ghost" onClick={() => setIsCreateModalOpen(false)}>Cancel</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setIsCreateModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -225,28 +363,58 @@ export default function ShoppingListsPage() {
|
|||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Scan Meal Plan Gaps</h3>
|
||||
<p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 20 }}>
|
||||
Select a scheduled weekly plan. We will cross-reference your recipe ingredient requirements vs active pantry inventory to auto-generate your grocery shortages!
|
||||
Select a scheduled weekly plan. We will cross-reference your recipe ingredient
|
||||
requirements vs active pantry inventory to auto-generate your grocery shortages!
|
||||
</p>
|
||||
|
||||
|
||||
{mealPlanLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>Loading schedules...</div>
|
||||
) : recentMealPlans.length === 0 ? (
|
||||
<div style={{ padding: 16, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, textAlign: 'center', fontSize: 14, color: 'var(--ink-muted)' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
color: 'var(--ink-muted)',
|
||||
}}
|
||||
>
|
||||
No meal plans configured. Build a plan first!
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxHeight: 300, overflowY: 'auto', marginBottom: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{recentMealPlans.slice(0, 5).map((plan) => {
|
||||
const dateStr = new Date(plan.weekStartDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const dateStr = new Date(plan.weekStartDate).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={plan._id}
|
||||
onClick={() => handleGenerateFromPlan(plan._id)}
|
||||
onClick={() => {
|
||||
void handleGenerateFromPlan(plan._id);
|
||||
}}
|
||||
style={planRowStyle}
|
||||
>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 14 }}>Week of {dateStr}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-muted)' }}>Status: <span style={{ textTransform: 'capitalize' }}>{plan.status}</span></div>
|
||||
<div style={{ fontWeight: 600, color: 'var(--ink)', fontSize: 14 }}>
|
||||
Week of {dateStr}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-muted)' }}>
|
||||
Status: <span style={{ textTransform: 'capitalize' }}>{plan.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Icon name="chevronRight" style={{ width: 16, color: 'var(--ink-muted)' }} />
|
||||
</button>
|
||||
|
|
@ -254,9 +422,11 @@ export default function ShoppingListsPage() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="ghost" onClick={() => setIsGapModalOpen(false)}>Close</Button>
|
||||
<Button variant="ghost" onClick={() => setIsGapModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -265,62 +435,159 @@ export default function ShoppingListsPage() {
|
|||
);
|
||||
}
|
||||
|
||||
function MetricCard({ icon, title, value, subtitle, color, link }: any) {
|
||||
function MetricCard({ icon, title, value, subtitle, color, link }: MetricCardProps) {
|
||||
const content = (
|
||||
<Card style={{ padding: 20, height: '100%', display: 'flex', alignItems: 'center', gap: 16, position: 'relative', overflow: 'hidden', cursor: link ? 'pointer' : 'default' }}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: '50%', background: `${color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Card
|
||||
style={{
|
||||
padding: 20,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: link ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
background: `${color}15`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name={icon} style={{ width: 22, height: 22, color }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--ink-muted)', textTransform: 'uppercase', letterSpacing: '0.02em' }}>{title}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--ink)', margin: '2px 0' }}>{value}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'var(--ink-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--ink)', margin: '2px 0' }}>
|
||||
{value}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--ink-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
{link && <Icon name="chevronRight" style={{ width: 12 }} />}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
return link ? <Link href={link} style={{ textDecoration: 'none' }}>{content}</Link> : content;
|
||||
return link ? (
|
||||
<Link href={link} style={{ textDecoration: 'none' }}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
function ShoppingListCard({ list }: { list: any }) {
|
||||
function ShoppingListCard({ list }: { list: ShoppingListResponse }) {
|
||||
const total = list.items.length;
|
||||
const checked = list.items.filter((i: any) => i.checked).length;
|
||||
const checked = list.items.filter((i) => i.checked).length;
|
||||
const progress = total > 0 ? Math.round((checked / total) * 100) : 0;
|
||||
const date = new Date(list.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
|
||||
const date = new Date(list.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const isActive = list.status === 'active' || list.status === 'shopping';
|
||||
|
||||
|
||||
return (
|
||||
<Link href={`/shopping-lists/${list._id}`} style={{ textDecoration: 'none' }}>
|
||||
<Card style={{
|
||||
padding: 20,
|
||||
transition: 'all 0.2s ease',
|
||||
border: isActive ? '1px solid var(--border-hover, #444)' : '1px solid var(--border)',
|
||||
position: 'relative',
|
||||
background: isActive ? 'rgba(255,255,255,0.02)' : 'var(--bg-elev)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||||
<Card
|
||||
style={{
|
||||
padding: 20,
|
||||
transition: 'all 0.2s ease',
|
||||
border: isActive ? '1px solid var(--border-hover, #444)' : '1px solid var(--border)',
|
||||
position: 'relative',
|
||||
background: isActive ? 'rgba(255,255,255,0.02)' : 'var(--bg-elev)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', margin: 0, lineHeight: 1.3 }}>{list.name}</h4>
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-muted)', display: 'inline-block', marginTop: 4 }}>Created {date}</span>
|
||||
<h4
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink)',
|
||||
margin: 0,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{list.name}
|
||||
</h4>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--ink-muted)',
|
||||
display: 'inline-block',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
Created {date}
|
||||
</span>
|
||||
</div>
|
||||
<Pill tone={list.status === 'active' ? 'info' : list.status === 'shopping' ? 'warn' : 'ghost'}>
|
||||
<Pill
|
||||
tone={list.status === 'active' ? 'info' : list.status === 'shopping' ? 'warn' : 'ghost'}
|
||||
>
|
||||
{list.status === 'shopping' ? 'Live' : list.status}
|
||||
</Pill>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-muted)', display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: 'var(--ink-muted)',
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Icon name="list" style={{ width: 14 }} /> {checked}/{total} items
|
||||
</span>
|
||||
{list.totalEstimatedCost && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontWeight: 600, color: 'var(--ink)' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
${list.totalEstimatedCost.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -328,12 +595,30 @@ function ShoppingListCard({ list }: { list: any }) {
|
|||
|
||||
{/* Custom Progress Bar */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--ink-muted)', marginBottom: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 11,
|
||||
color: 'var(--ink-muted)',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<span>Progress</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${progress}%`, background: progress === 100 ? 'var(--success, #10b981)' : 'var(--brand)', borderRadius: 3, transition: 'width 0.4s cubic-bezier(0.4, 0, 0.2, 1)' }} />
|
||||
<div
|
||||
style={{ height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progress}%`,
|
||||
background: progress === 100 ? 'var(--success, #10b981)' : 'var(--brand)',
|
||||
borderRadius: 3,
|
||||
transition: 'width 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -342,26 +627,50 @@ function ShoppingListCard({ list }: { list: any }) {
|
|||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(6px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999,
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: 16,
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: 'var(--bg-elev)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)', padding: 24, width: '100%', maxWidth: 460,
|
||||
background: 'var(--bg-elev)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
padding: 24,
|
||||
width: '100%',
|
||||
maxWidth: 460,
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px', borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg)', border: '1px solid var(--border)', color: 'var(--ink)',
|
||||
fontSize: 14, outline: 'none',
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--ink)',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const planRowStyle: React.CSSProperties = {
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '12px 16px', background: 'var(--bg)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)', width: '100%', cursor: 'pointer', transition: 'all 0.15s',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { useState, useEffect, useCallback } from 'react';
|
|||
import { useApi } from '@/lib/useApi';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SetPageHeader } from '@/components/layout/SetPageHeader';
|
||||
import { Card, Button, Icon, Pill } from '@/components/ui';
|
||||
import { Card, Button, Icon } from '@/components/ui';
|
||||
import type { FoodSpendingAnalyticsResponse } from '@/services/prices';
|
||||
import { getPriceAnalytics } from '@/services/prices';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
|
|
@ -16,14 +17,25 @@ import {
|
|||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
interface SpendingByCategoryItem {
|
||||
category: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface AverageBasketByStoreItem {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
avgTotal: number;
|
||||
tripCount: number;
|
||||
}
|
||||
|
||||
export default function PricesAnalyticsPage() {
|
||||
const { householdId, isLoading } = useApi();
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [data, setData] = useState<FoodSpendingAnalyticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
|
|
@ -33,21 +45,31 @@ export default function PricesAnalyticsPage() {
|
|||
try {
|
||||
const result = await getPriceAnalytics(householdId);
|
||||
setData(result);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load analytics');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load analytics';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [householdId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics();
|
||||
void loadAnalytics();
|
||||
}, [loadAnalytics]);
|
||||
|
||||
if (isLoading || loading) return <div style={{ padding: 40 }}>Synthesizing financial graphs...</div>;
|
||||
if (error || !data) return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
|
||||
if (isLoading || loading)
|
||||
return <div style={{ padding: 40 }}>Synthesizing financial graphs...</div>;
|
||||
if (error || !data)
|
||||
return <div style={{ padding: 40, color: 'var(--danger)' }}>Error: {error}</div>;
|
||||
|
||||
const COLORS = ['var(--brand)', 'var(--success)', 'var(--warning)', '#a855f7', '#ec4899', '#3b82f6'];
|
||||
const COLORS = [
|
||||
'var(--brand)',
|
||||
'var(--success)',
|
||||
'var(--warning)',
|
||||
'#a855f7',
|
||||
'#ec4899',
|
||||
'#3b82f6',
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -57,7 +79,6 @@ export default function PricesAnalyticsPage() {
|
|||
/>
|
||||
|
||||
<div style={{ padding: '28px 32px 64px', maxWidth: 1400, margin: '0 auto' }}>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Button variant="ghost" onClick={() => router.push('/shopping-lists')}>
|
||||
<Icon name="chevronLeft" style={{ marginRight: 6, width: 16 }} /> Back to Checklists
|
||||
|
|
@ -66,34 +87,76 @@ export default function PricesAnalyticsPage() {
|
|||
|
||||
{/* 1. Immediate Red Alert Banner: Inflation Markup >10% */}
|
||||
{data.priceAlerts.length > 0 && (
|
||||
<div style={{
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
padding: 20, marginBottom: 32,
|
||||
display: 'flex', gap: 16, alignItems: 'flex-start'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
background: 'var(--danger)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
padding: 20,
|
||||
marginBottom: 32,
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--danger)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name="alert" style={{ width: 20, color: '#fff' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 6 }}>Significant Inflation Markers Detected</h4>
|
||||
<p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 16 }}>The following item markups exceeded the baseline 10% deviation thresholds compared to their trailing averages:</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
|
||||
{data.priceAlerts.map((alert: any, idx: number) => (
|
||||
<div key={idx} style={{ padding: 12, background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 'var(--r-md)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h4 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 6 }}>
|
||||
Significant Inflation Markers Detected
|
||||
</h4>
|
||||
<p style={{ fontSize: 13, color: 'var(--ink-muted)', marginBottom: 16 }}>
|
||||
The following item markups exceeded the baseline 10% deviation thresholds compared
|
||||
to their trailing averages:
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{data.priceAlerts.map((alert, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: 12,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--ink)' }}>{alert.productName}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>At {alert.storeName}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--ink)' }}>
|
||||
{alert.productName}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>
|
||||
At {alert.storeName}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--danger)', fontWeight: 700, fontSize: 14 }}>+{alert.changePercent.toFixed(0)}%</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>${alert.previousPrice} ➔ ${alert.currentPrice}</div>
|
||||
<div style={{ color: 'var(--danger)', fontWeight: 700, fontSize: 14 }}>
|
||||
+{alert.changePercent.toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>
|
||||
${alert.previousPrice} ➔ ${alert.currentPrice}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -103,22 +166,44 @@ export default function PricesAnalyticsPage() {
|
|||
)}
|
||||
|
||||
{/* 2. Grid Layout for Interactive Charts */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(500px, 1fr))', gap: 28, marginBottom: 32 }}>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(500px, 1fr))',
|
||||
gap: 28,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
{/* Time Series Spend Trend */}
|
||||
<Card style={{ padding: 24 }}>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Monthly Spending Velocities</h4>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>
|
||||
Monthly Spending Velocities
|
||||
</h4>
|
||||
<div style={{ height: 300 }}>
|
||||
{data.spendingOverTime.length === 0 ? (
|
||||
<div style={emptyStyle}>No historical spend records found.</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data.spendingOverTime} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||
<LineChart
|
||||
data={data.spendingOverTime}
|
||||
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis dataKey="period" stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
stroke="var(--ink-muted)"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Line type="monotone" dataKey="total" stroke="var(--brand)" strokeWidth={3} activeDot={{ r: 6 }} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="var(--brand)"
|
||||
strokeWidth={3}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
|
@ -127,21 +212,33 @@ export default function PricesAnalyticsPage() {
|
|||
|
||||
{/* Category Distribution */}
|
||||
<Card style={{ padding: 24 }}>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Spending Distrubution by Category</h4>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>
|
||||
Spending Distribution by Category
|
||||
</h4>
|
||||
<div style={{ height: 300 }}>
|
||||
{data.spendingByCategory.length === 0 ? (
|
||||
<div style={emptyStyle}>No categorized allocations recorded yet.</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.spendingByCategory} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||
<BarChart
|
||||
data={data.spendingByCategory}
|
||||
margin={{ top: 5, right: 10, left: -20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis dataKey="category" stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
|
||||
<XAxis
|
||||
dataKey="category"
|
||||
stroke="var(--ink-muted)"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis stroke="var(--ink-muted)" fontSize={11} tickLine={false} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Bar dataKey="total" radius={[4, 4, 0, 0]}>
|
||||
{data.spendingByCategory.map((entry: any, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
{(data.spendingByCategory as SpendingByCategoryItem[]).map(
|
||||
(_entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
),
|
||||
)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
|
@ -152,46 +249,99 @@ export default function PricesAnalyticsPage() {
|
|||
|
||||
{/* 3. Average Basket Comparisons (Grid of Stores) */}
|
||||
<Card style={{ padding: 24 }}>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>Average Complete Basket Totals per Store</h4>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, marginBottom: 20 }}>
|
||||
Average Complete Basket Totals per Store
|
||||
</h4>
|
||||
{data.averageBasketByStore.length === 0 ? (
|
||||
<div style={emptyStyle}>Create multiple shopping trips to visualize basket trends.</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 20 }}>
|
||||
{data.averageBasketByStore.sort((a:any, b:any) => a.avgTotal - b.avgTotal).map((store: any, idx: number) => (
|
||||
<div key={store.storeId} style={{
|
||||
padding: 20, background: 'var(--bg-elev)',
|
||||
border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)', position: 'relative', overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-muted)', textTransform: 'uppercase', marginBottom: 8 }}>{store.storeName}</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: idx === 0 ? 'var(--success)' : 'var(--ink)' }}>${store.avgTotal.toFixed(2)}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 4 }}>Based on {store.tripCount} simulated checkouts</div>
|
||||
{idx === 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, right: 0,
|
||||
background: 'var(--success)', color: '#fff',
|
||||
fontSize: 9, padding: '4px 8px', borderBottomLeftRadius: 'var(--r-sm)',
|
||||
fontWeight: 700, textTransform: 'uppercase'
|
||||
}}>Best Value</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{(data.averageBasketByStore as AverageBasketByStoreItem[])
|
||||
.sort((a, b) => a.avgTotal - b.avgTotal)
|
||||
.map((store, idx) => (
|
||||
<div
|
||||
key={store.storeId}
|
||||
style={{
|
||||
padding: 20,
|
||||
background: 'var(--bg-elev)',
|
||||
border: idx === 0 ? '1px solid var(--success)' : '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--ink-muted)',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{store.storeName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: idx === 0 ? 'var(--success)' : 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
${store.avgTotal.toFixed(2)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-muted)', marginTop: 4 }}>
|
||||
Based on {store.tripCount} simulated checkouts
|
||||
</div>
|
||||
{idx === 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: 'var(--success)',
|
||||
color: '#fff',
|
||||
fontSize: 9,
|
||||
padding: '4px 8px',
|
||||
borderBottomLeftRadius: 'var(--r-sm)',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Best Value
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100%', color: 'var(--ink-muted)', fontSize: 13, border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--r-md)'
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: 'var(--ink-muted)',
|
||||
fontSize: 13,
|
||||
border: '1px dashed var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
};
|
||||
|
||||
const tooltipStyle: React.CSSProperties = {
|
||||
background: '#1f2937', border: '1px solid #374151', borderRadius: 8,
|
||||
color: '#fff', fontSize: 12,
|
||||
background: '#1f2937',
|
||||
border: '1px solid #374151',
|
||||
borderRadius: 8,
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -332,14 +332,14 @@ function StoresContent({ householdId }: { householdId: string }) {
|
|||
}, [householdId, search, filterTag]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStores();
|
||||
void fetchStores();
|
||||
}, [fetchStores]);
|
||||
|
||||
async function handleDeactivate(store: Store) {
|
||||
if (!confirm(`Deactivate "${store.name}"?`)) return;
|
||||
try {
|
||||
await deactivateStore(householdId, store._id);
|
||||
fetchStores();
|
||||
void fetchStores();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to deactivate store');
|
||||
}
|
||||
|
|
@ -376,7 +376,7 @@ function StoresContent({ householdId }: { householdId: string }) {
|
|||
householdId={householdId}
|
||||
onSaved={() => {
|
||||
setShowForm(false);
|
||||
fetchStores();
|
||||
void fetchStores();
|
||||
}}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
|
|
@ -388,7 +388,7 @@ function StoresContent({ householdId }: { householdId: string }) {
|
|||
initial={editingStore}
|
||||
onSaved={() => {
|
||||
setEditingStore(null);
|
||||
fetchStores();
|
||||
void fetchStores();
|
||||
}}
|
||||
onCancel={() => setEditingStore(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,20 +13,68 @@ interface AccentTokens {
|
|||
|
||||
const ACCENTS: Record<Accent, AccentTokens> = {
|
||||
sage: {
|
||||
light: { brand: '#10b981', deep: '#059669', soft: 'rgba(16,185,129,0.1)', softInk: '#047857', brandInk: '#ffffff' },
|
||||
dark: { brand: '#34d399', deep: '#10b981', soft: 'rgba(52,211,153,0.15)', softInk: '#6ee7b7', brandInk: '#022c22' },
|
||||
light: {
|
||||
brand: '#10b981',
|
||||
deep: '#059669',
|
||||
soft: 'rgba(16,185,129,0.1)',
|
||||
softInk: '#047857',
|
||||
brandInk: '#ffffff',
|
||||
},
|
||||
dark: {
|
||||
brand: '#34d399',
|
||||
deep: '#10b981',
|
||||
soft: 'rgba(52,211,153,0.15)',
|
||||
softInk: '#6ee7b7',
|
||||
brandInk: '#022c22',
|
||||
},
|
||||
},
|
||||
cobalt: {
|
||||
light: { brand: '#3b82f6', deep: '#2563eb', soft: 'rgba(59,130,246,0.1)', softInk: '#1d4ed8', brandInk: '#ffffff' },
|
||||
dark: { brand: '#60a5fa', deep: '#3b82f6', soft: 'rgba(96,165,250,0.15)', softInk: '#93c5fd', brandInk: '#172554' },
|
||||
light: {
|
||||
brand: '#3b82f6',
|
||||
deep: '#2563eb',
|
||||
soft: 'rgba(59,130,246,0.1)',
|
||||
softInk: '#1d4ed8',
|
||||
brandInk: '#ffffff',
|
||||
},
|
||||
dark: {
|
||||
brand: '#60a5fa',
|
||||
deep: '#3b82f6',
|
||||
soft: 'rgba(96,165,250,0.15)',
|
||||
softInk: '#93c5fd',
|
||||
brandInk: '#172554',
|
||||
},
|
||||
},
|
||||
terracotta: {
|
||||
light: { brand: '#f43f5e', deep: '#e11d48', soft: 'rgba(244,63,94,0.1)', softInk: '#be123c', brandInk: '#ffffff' },
|
||||
dark: { brand: '#fb7185', deep: '#f43f5e', soft: 'rgba(251,113,133,0.15)', softInk: '#fda4af', brandInk: '#4c0519' },
|
||||
light: {
|
||||
brand: '#f43f5e',
|
||||
deep: '#e11d48',
|
||||
soft: 'rgba(244,63,94,0.1)',
|
||||
softInk: '#be123c',
|
||||
brandInk: '#ffffff',
|
||||
},
|
||||
dark: {
|
||||
brand: '#fb7185',
|
||||
deep: '#f43f5e',
|
||||
soft: 'rgba(251,113,133,0.15)',
|
||||
softInk: '#fda4af',
|
||||
brandInk: '#4c0519',
|
||||
},
|
||||
},
|
||||
graphite: {
|
||||
light: { brand: '#52525b', deep: '#3f3f46', soft: 'rgba(82,82,91,0.1)', softInk: '#27272a', brandInk: '#ffffff' },
|
||||
dark: { brand: '#a1a1aa', deep: '#71717a', soft: 'rgba(161,161,170,0.15)', softInk: '#d4d4d8', brandInk: '#18181b' },
|
||||
light: {
|
||||
brand: '#52525b',
|
||||
deep: '#3f3f46',
|
||||
soft: 'rgba(82,82,91,0.1)',
|
||||
softInk: '#27272a',
|
||||
brandInk: '#ffffff',
|
||||
},
|
||||
dark: {
|
||||
brand: '#a1a1aa',
|
||||
deep: '#71717a',
|
||||
soft: 'rgba(161,161,170,0.15)',
|
||||
softInk: '#d4d4d8',
|
||||
brandInk: '#18181b',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getShoppingListSyncSocketUrl } from '@/services/shopping-lists';
|
||||
|
||||
export interface SyncUpdateMessage {
|
||||
type: 'ITEM_ADDED' | 'ITEM_UPDATED' | 'ITEM_REMOVED';
|
||||
itemId?: string;
|
||||
item?: any;
|
||||
updates?: any;
|
||||
item?: unknown;
|
||||
updates?: unknown;
|
||||
}
|
||||
|
||||
export function useShoppingListSync(
|
||||
householdId: string,
|
||||
listId: string,
|
||||
onRemoteChange: (msg: SyncUpdateMessage) => void
|
||||
onRemoteChange: (msg: SyncUpdateMessage) => void,
|
||||
) {
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
|
@ -40,7 +41,7 @@ export function useShoppingListSync(
|
|||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const payload: SyncUpdateMessage = JSON.parse(event.data);
|
||||
const payload = JSON.parse(event.data) as SyncUpdateMessage;
|
||||
onRemoteChange(payload);
|
||||
} catch (err) {
|
||||
console.error('Failed parsing real-time grocery payload', err);
|
||||
|
|
@ -54,13 +55,15 @@ export function useShoppingListSync(
|
|||
ws.onclose = (event) => {
|
||||
setIsConnected(false);
|
||||
console.log(`🔌 Sync severed: ${event.reason || 'Disconnected'}`);
|
||||
|
||||
|
||||
// Simple linear backoff reconnect
|
||||
if (reconnectAttemptsRef.current < 5) {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
const delay = Math.min(1000 * reconnectAttemptsRef.current, 5000);
|
||||
setTimeout(() => {
|
||||
console.log(`🔄 Attempting sync handshake reconnect (${reconnectAttemptsRef.current}/5)...`);
|
||||
console.log(
|
||||
`🔄 Attempting sync handshake reconnect (${reconnectAttemptsRef.current}/5)...`,
|
||||
);
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
|
|
@ -76,7 +79,7 @@ export function useShoppingListSync(
|
|||
return () => {
|
||||
if (socketRef.current) {
|
||||
// Clear hook handlers to prevent state leakage during dismount
|
||||
socketRef.current.onclose = null;
|
||||
socketRef.current.onclose = null;
|
||||
socketRef.current.close();
|
||||
}
|
||||
};
|
||||
|
|
@ -89,7 +92,7 @@ export function useShoppingListSync(
|
|||
type: 'TOGGLE_ITEM',
|
||||
itemId,
|
||||
checked,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -15,33 +15,6 @@ class ApiClient {
|
|||
return BASE_URL;
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this._accessToken) {
|
||||
headers['Authorization'] = `Bearer ${this._accessToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async handleResponse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let message: string;
|
||||
try {
|
||||
const body = await res.json();
|
||||
message = body.message || `Request failed: ${res.status}`;
|
||||
} catch {
|
||||
message = `Request failed: ${res.status} ${res.statusText}`;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
public async get<T>(url: string): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${url}`, {
|
||||
headers: this.getHeaders(),
|
||||
|
|
@ -78,6 +51,33 @@ class ApiClient {
|
|||
});
|
||||
return this.handleResponse<T>(res);
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this._accessToken) {
|
||||
headers['Authorization'] = `Bearer ${this._accessToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async handleResponse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
let message: string;
|
||||
try {
|
||||
const body = (await res.json()) as { message?: string };
|
||||
message = body.message || `Request failed: ${res.status}`;
|
||||
} catch {
|
||||
message = `Request failed: ${res.status} ${res.statusText}`;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
|
|
|||
|
|
@ -44,88 +44,65 @@ export interface ShoppingGapReport {
|
|||
|
||||
export async function listMealPlans(
|
||||
householdId: string,
|
||||
query?: MealPlanQuery
|
||||
query?: MealPlanQuery,
|
||||
): Promise<MealPlanListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.cursor) params.set('cursor', query.cursor);
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<MealPlanListResponse>(
|
||||
`/households/${householdId}/meal-plans${qs ? `?${qs}` : ''}`
|
||||
`/households/${householdId}/meal-plans${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMealPlanByWeek(
|
||||
householdId: string,
|
||||
weekStartDate: string
|
||||
weekStartDate: string,
|
||||
): Promise<MealPlanResponse | { message: string }> {
|
||||
return apiClient.get<MealPlanResponse | { message: string }>(
|
||||
`/households/${householdId}/meal-plans/week/${weekStartDate}`
|
||||
`/households/${householdId}/meal-plans/week/${weekStartDate}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMealPlan(
|
||||
householdId: string,
|
||||
id: string
|
||||
): Promise<MealPlanResponse> {
|
||||
return apiClient.get<MealPlanResponse>(
|
||||
`/households/${householdId}/meal-plans/${id}`
|
||||
);
|
||||
export async function getMealPlan(householdId: string, id: string): Promise<MealPlanResponse> {
|
||||
return apiClient.get<MealPlanResponse>(`/households/${householdId}/meal-plans/${id}`);
|
||||
}
|
||||
|
||||
export async function createMealPlan(
|
||||
householdId: string,
|
||||
data: CreateMealPlanInput
|
||||
data: CreateMealPlanInput,
|
||||
): Promise<MealPlanResponse> {
|
||||
return apiClient.post<MealPlanResponse>(
|
||||
`/households/${householdId}/meal-plans`,
|
||||
data
|
||||
);
|
||||
return apiClient.post<MealPlanResponse>(`/households/${householdId}/meal-plans`, data);
|
||||
}
|
||||
|
||||
export async function updateMealPlan(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdateMealPlanInput
|
||||
data: UpdateMealPlanInput,
|
||||
): Promise<MealPlanResponse> {
|
||||
return apiClient.patch<MealPlanResponse>(
|
||||
`/households/${householdId}/meal-plans/${id}`,
|
||||
data
|
||||
);
|
||||
return apiClient.patch<MealPlanResponse>(`/households/${householdId}/meal-plans/${id}`, data);
|
||||
}
|
||||
|
||||
export async function updateMealPlanStatus(
|
||||
householdId: string,
|
||||
id: string,
|
||||
status: MealPlanStatus
|
||||
status: MealPlanStatus,
|
||||
): Promise<MealPlanResponse> {
|
||||
return apiClient.patch<MealPlanResponse>(
|
||||
`/households/${householdId}/meal-plans/${id}/status`,
|
||||
{ status }
|
||||
);
|
||||
return apiClient.patch<MealPlanResponse>(`/households/${householdId}/meal-plans/${id}/status`, {
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMealPlan(
|
||||
householdId: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
export async function deleteMealPlan(householdId: string, id: string): Promise<void> {
|
||||
return apiClient.delete(`/households/${householdId}/meal-plans/${id}`);
|
||||
}
|
||||
|
||||
export async function getSuggestions(
|
||||
householdId: string,
|
||||
limit = 5
|
||||
): Promise<RecipeSuggestion[]> {
|
||||
export async function getSuggestions(householdId: string, limit = 5): Promise<RecipeSuggestion[]> {
|
||||
return apiClient.get<RecipeSuggestion[]>(
|
||||
`/households/${householdId}/meal-plans/suggestions?limit=${limit}`
|
||||
`/households/${householdId}/meal-plans/suggestions?limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getShoppingGap(
|
||||
householdId: string,
|
||||
id: string
|
||||
): Promise<ShoppingGapReport> {
|
||||
return apiClient.get<ShoppingGapReport>(
|
||||
`/households/${householdId}/meal-plans/${id}/gap`
|
||||
);
|
||||
export async function getShoppingGap(householdId: string, id: string): Promise<ShoppingGapReport> {
|
||||
return apiClient.get<ShoppingGapReport>(`/households/${householdId}/meal-plans/${id}/gap`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,42 @@
|
|||
import { apiClient } from './api-client';
|
||||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
NutritionTargetResponseSchema,
|
||||
SetNutritionTargetInput,
|
||||
} from '@meshitrack/shared';
|
||||
import type { NutritionTargetResponseSchema, SetNutritionTargetInput } from '@meshitrack/shared';
|
||||
|
||||
export type NutritionTargetResponse = z.infer<typeof NutritionTargetResponseSchema>;
|
||||
|
||||
export async function getActiveNutritionTarget(
|
||||
householdId: string
|
||||
householdId: string,
|
||||
): Promise<NutritionTargetResponse | { message: string }> {
|
||||
return apiClient.get<NutritionTargetResponse | { message: string }>(
|
||||
`/households/${householdId}/nutrition-targets`
|
||||
`/households/${householdId}/nutrition-targets`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getNutritionTargetHistory(
|
||||
householdId: string
|
||||
householdId: string,
|
||||
): Promise<NutritionTargetResponse[]> {
|
||||
return apiClient.get<NutritionTargetResponse[]>(
|
||||
`/households/${householdId}/nutrition-targets/history`
|
||||
`/households/${householdId}/nutrition-targets/history`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function setNutritionTarget(
|
||||
householdId: string,
|
||||
data: SetNutritionTargetInput
|
||||
data: SetNutritionTargetInput,
|
||||
): Promise<NutritionTargetResponse> {
|
||||
return apiClient.post<NutritionTargetResponse>(
|
||||
`/households/${householdId}/nutrition-targets`,
|
||||
data
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function calculateTargetPreset(
|
||||
householdId: string,
|
||||
calories: number,
|
||||
strategy: 'maintenance' | 'loss' | 'gain'
|
||||
strategy: 'maintenance' | 'loss' | 'gain',
|
||||
): Promise<SetNutritionTargetInput> {
|
||||
return apiClient.post<SetNutritionTargetInput>(
|
||||
`/households/${householdId}/nutrition-targets/presets`,
|
||||
{ calories, strategy }
|
||||
{ calories, strategy },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,20 @@ import type {
|
|||
type PriceRecordResponse = z.infer<typeof PriceRecordResponseSchema>;
|
||||
type PriceHistoryResponse = z.infer<typeof PriceHistoryResponseSchema>;
|
||||
type FoodStoreComparisonResponse = z.infer<typeof FoodStoreComparisonResponseSchema>;
|
||||
type FoodSpendingAnalyticsResponse = z.infer<typeof FoodSpendingAnalyticsResponseSchema>;
|
||||
export type FoodSpendingAnalyticsResponse = z.infer<typeof FoodSpendingAnalyticsResponseSchema>;
|
||||
type CreatePriceRecordInput = z.infer<typeof CreatePriceRecordSchema>;
|
||||
type BulkPriceRecordInput = z.infer<typeof BulkPriceRecordInputSchema>;
|
||||
|
||||
export async function recordPrice(
|
||||
householdId: string,
|
||||
data: CreatePriceRecordInput
|
||||
data: CreatePriceRecordInput,
|
||||
): Promise<PriceRecordResponse> {
|
||||
return apiClient.post<PriceRecordResponse>(`/households/${householdId}/prices`, data);
|
||||
}
|
||||
|
||||
export async function recordBulkPrices(
|
||||
householdId: string,
|
||||
data: BulkPriceRecordInput
|
||||
data: BulkPriceRecordInput,
|
||||
): Promise<PriceRecordResponse[]> {
|
||||
return apiClient.post<PriceRecordResponse[]>(`/households/${householdId}/prices/bulk`, data);
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ export async function getPriceHistory(
|
|||
endDate?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
},
|
||||
): Promise<PriceHistoryResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.storeId) params.set('storeId', query.storeId);
|
||||
|
|
@ -49,23 +49,23 @@ export async function getPriceHistory(
|
|||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return apiClient.get<PriceHistoryResponse>(
|
||||
`/households/${householdId}/prices/history/${productId}${qs ? `?${qs}` : ''}`
|
||||
`/households/${householdId}/prices/history/${productId}${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function compareStores(
|
||||
householdId: string,
|
||||
productId: string
|
||||
productId: string,
|
||||
): Promise<FoodStoreComparisonResponse> {
|
||||
return apiClient.get<FoodStoreComparisonResponse>(
|
||||
`/households/${householdId}/prices/compare/${productId}`
|
||||
`/households/${householdId}/prices/compare/${productId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getPriceAnalytics(
|
||||
householdId: string
|
||||
householdId: string,
|
||||
): Promise<FoodSpendingAnalyticsResponse> {
|
||||
return apiClient.get<FoodSpendingAnalyticsResponse>(
|
||||
`/households/${householdId}/prices/analytics`
|
||||
`/households/${householdId}/prices/analytics`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import type {
|
|||
UpdateProductInput,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type ProductResponse = z.infer<typeof ProductResponseSchema>;
|
||||
type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
|
||||
export type ProductResponse = z.infer<typeof ProductResponseSchema>;
|
||||
export type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
|
||||
|
||||
export interface ProductQuery {
|
||||
q?: string;
|
||||
|
|
@ -96,12 +96,16 @@ export async function importProducts(
|
|||
if (!res.ok) {
|
||||
let message: string;
|
||||
try {
|
||||
const body = await res.json();
|
||||
const body = (await res.json()) as { message?: string };
|
||||
message = body.message || `Import failed: ${res.status}`;
|
||||
} catch {
|
||||
message = `Import failed: ${res.status} ${res.statusText}`;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
return res.json() as Promise<{
|
||||
imported: number;
|
||||
skippedDuplicates: number;
|
||||
errors: { row: number; message: string }[];
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { apiClient } from './api-client';
|
|||
import type { z } from 'zod/v4';
|
||||
import type {
|
||||
ShoppingListResponseSchema,
|
||||
ShoppingItemSchema,
|
||||
CreateShoppingListSchema,
|
||||
UpdateShoppingListSchema,
|
||||
AddShoppingItemSchema,
|
||||
|
|
@ -10,25 +11,31 @@ import type {
|
|||
BasketStoreComparisonResponseSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type ShoppingListResponse = z.infer<typeof ShoppingListResponseSchema>;
|
||||
type CreateShoppingListInput = z.infer<typeof CreateShoppingListSchema>;
|
||||
type UpdateShoppingListInput = z.infer<typeof UpdateShoppingListSchema>;
|
||||
type AddShoppingItemInput = z.infer<typeof AddShoppingItemSchema>;
|
||||
type UpdateShoppingItemInput = z.infer<typeof UpdateShoppingItemSchema>;
|
||||
type ShoppingListSyncToPantryResponse = z.infer<typeof ShoppingListSyncToPantryResponseSchema>;
|
||||
type BasketStoreComparisonResponse = z.infer<typeof BasketStoreComparisonResponseSchema>;
|
||||
export type ShoppingListResponse = z.infer<typeof ShoppingListResponseSchema>;
|
||||
export type ShoppingItem = z.infer<typeof ShoppingItemSchema>;
|
||||
export type CreateShoppingListInput = z.infer<typeof CreateShoppingListSchema>;
|
||||
export type UpdateShoppingListInput = z.infer<typeof UpdateShoppingListSchema>;
|
||||
export type AddShoppingItemInput = z.infer<typeof AddShoppingItemSchema>;
|
||||
export type UpdateShoppingItemInput = z.infer<typeof UpdateShoppingItemSchema>;
|
||||
export type ShoppingListSyncToPantryResponse = z.infer<
|
||||
typeof ShoppingListSyncToPantryResponseSchema
|
||||
>;
|
||||
export type BasketStoreComparisonResponse = z.infer<typeof BasketStoreComparisonResponseSchema>;
|
||||
|
||||
export async function getShoppingLists(householdId: string): Promise<ShoppingListResponse[]> {
|
||||
return apiClient.get<ShoppingListResponse[]>(`/households/${householdId}/shopping-lists`);
|
||||
}
|
||||
|
||||
export async function getShoppingList(householdId: string, id: string): Promise<ShoppingListResponse> {
|
||||
export async function getShoppingList(
|
||||
householdId: string,
|
||||
id: string,
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.get<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}`);
|
||||
}
|
||||
|
||||
export async function createShoppingList(
|
||||
householdId: string,
|
||||
data: CreateShoppingListInput
|
||||
data: CreateShoppingListInput,
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.post<ShoppingListResponse>(`/households/${householdId}/shopping-lists`, data);
|
||||
}
|
||||
|
|
@ -36,9 +43,12 @@ export async function createShoppingList(
|
|||
export async function updateShoppingList(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: UpdateShoppingListInput
|
||||
data: UpdateShoppingListInput,
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.patch<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}`, data);
|
||||
return apiClient.patch<ShoppingListResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteShoppingList(householdId: string, id: string): Promise<void> {
|
||||
|
|
@ -50,30 +60,33 @@ export async function deleteShoppingList(householdId: string, id: string): Promi
|
|||
export async function addShoppingItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
data: AddShoppingItemInput
|
||||
data: AddShoppingItemInput,
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.post<ShoppingListResponse>(`/households/${householdId}/shopping-lists/${id}/items`, data);
|
||||
return apiClient.post<ShoppingListResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}/items`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateShoppingItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
itemId: string,
|
||||
data: UpdateShoppingItemInput
|
||||
data: UpdateShoppingItemInput,
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.patch<ShoppingListResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}/items/${itemId}`,
|
||||
data
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeShoppingItem(
|
||||
householdId: string,
|
||||
id: string,
|
||||
itemId: string
|
||||
itemId: string,
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.delete<ShoppingListResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}/items/${itemId}`
|
||||
`/households/${householdId}/shopping-lists/${id}/items/${itemId}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -81,28 +94,28 @@ export async function removeShoppingItem(
|
|||
|
||||
export async function generateFromMealPlan(
|
||||
householdId: string,
|
||||
mealPlanId: string
|
||||
mealPlanId: string,
|
||||
): Promise<ShoppingListResponse> {
|
||||
return apiClient.post<ShoppingListResponse>(
|
||||
`/households/${householdId}/shopping-lists/from-meal-plan/${mealPlanId}`
|
||||
`/households/${householdId}/shopping-lists/from-meal-plan/${mealPlanId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncToPantry(
|
||||
householdId: string,
|
||||
id: string
|
||||
id: string,
|
||||
): Promise<ShoppingListSyncToPantryResponse> {
|
||||
return apiClient.post<ShoppingListSyncToPantryResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}/sync-to-pantry`
|
||||
`/households/${householdId}/shopping-lists/${id}/sync-to-pantry`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBasketStoreComparison(
|
||||
householdId: string,
|
||||
id: string
|
||||
id: string,
|
||||
): Promise<BasketStoreComparisonResponse> {
|
||||
return apiClient.get<BasketStoreComparisonResponse>(
|
||||
`/households/${householdId}/shopping-lists/${id}/stores`
|
||||
`/households/${householdId}/shopping-lists/${id}/stores`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import type {
|
|||
UpdateStoreSchema,
|
||||
} from '@meshitrack/shared';
|
||||
|
||||
type StoreResponse = z.infer<typeof StoreResponseSchema>;
|
||||
type StoreListResponse = z.infer<typeof StoreListResponseSchema>;
|
||||
type CreateStoreInput = z.infer<typeof CreateStoreSchema>;
|
||||
type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>;
|
||||
export type StoreResponse = z.infer<typeof StoreResponseSchema>;
|
||||
export type StoreListResponse = z.infer<typeof StoreListResponseSchema>;
|
||||
export type CreateStoreInput = z.infer<typeof CreateStoreSchema>;
|
||||
export type UpdateStoreInput = z.infer<typeof UpdateStoreSchema>;
|
||||
|
||||
export async function listStores(
|
||||
householdId: string,
|
||||
|
|
|
|||
|
|
@ -14,9 +14,20 @@ describe('ShoppingListsPage', () => {
|
|||
vi.clearAllMocks();
|
||||
vi.mocked(useApiModule.useApi).mockReturnValue({
|
||||
householdId: 'hh1',
|
||||
householdIds: ['hh1'],
|
||||
isLoading: false,
|
||||
user: null,
|
||||
token: '123',
|
||||
isAuthenticated: true,
|
||||
profile: {
|
||||
_id: 'user1',
|
||||
keycloakId: 'user1',
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
householdIds: ['hh1'],
|
||||
defaultHouseholdId: 'hh1',
|
||||
createdAt: '2026-05-10T00:00:00.000Z',
|
||||
updatedAt: '2026-05-10T00:00:00.000Z',
|
||||
},
|
||||
refreshProfile: vi.fn(),
|
||||
});
|
||||
vi.mocked(ShoppingListsService.getShoppingLists).mockResolvedValue([
|
||||
{ id: 'list2', name: 'Completed Costco', status: 'completed', items: [], totalEstimatedCost: 50, createdAt: '2026-05-09' } as any,
|
||||
|
|
@ -68,9 +79,7 @@ describe('ShoppingListsPage', () => {
|
|||
it('opens generate from meal plan modal', async () => {
|
||||
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
|
||||
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
pagination: { cursor: null, hasMore: false }
|
||||
});
|
||||
vi.mocked(ShoppingListsService.generateFromMealPlan).mockResolvedValue({
|
||||
id: 'list3', name: 'Generated', status: 'active', items: [], createdAt: '2026-05-11'
|
||||
|
|
@ -104,7 +113,7 @@ describe('ShoppingListsPage', () => {
|
|||
it('can cancel/close creation and gap scanning modals', async () => {
|
||||
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0, page: 1, limit: 10
|
||||
pagination: { cursor: null, hasMore: false }
|
||||
});
|
||||
|
||||
const { container } = render(<ShoppingListsPage />);
|
||||
|
|
@ -165,7 +174,7 @@ describe('ShoppingListsPage', () => {
|
|||
it('handles generate from meal plan failure', async () => {
|
||||
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
|
||||
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
|
||||
total: 1, page: 1, limit: 10
|
||||
pagination: { cursor: null, hasMore: false }
|
||||
});
|
||||
vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue(new Error('Gen failed'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
|
@ -314,7 +323,7 @@ describe('ShoppingListsPage', () => {
|
|||
it('handles gap scanner generation failure with generic error fallback', async () => {
|
||||
vi.mocked(MealPlansService.listMealPlans).mockResolvedValue({
|
||||
data: [{ _id: 'mp1', status: 'active', weekStartDate: '2026-05-10', days: [] } as any],
|
||||
total: 1, page: 1, limit: 10
|
||||
pagination: { cursor: null, hasMore: false }
|
||||
});
|
||||
vi.mocked(ShoppingListsService.generateFromMealPlan).mockRejectedValue({});
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ describe('Icon', () => {
|
|||
});
|
||||
|
||||
it('renders null for invalid icon name', () => {
|
||||
const { container } = render(<Icon name="nonexistent" as any />);
|
||||
const { container } = render(<Icon name={"nonexistent" as any} />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg?.childNodes.length).toBe(0);
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ describe('shopping-lists service', () => {
|
|||
const urlInsecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
|
||||
expect(urlInsecure).toBe('ws://localhost:3001/households/hh1/shopping-lists/list1/sync');
|
||||
|
||||
apiClient.baseUrl = 'https://api.meshitrack.com';
|
||||
(apiClient as any).baseUrl = 'https://api.meshitrack.com';
|
||||
const urlSecure = ShoppingListsService.getShoppingListSyncSocketUrl('hh1', 'list1');
|
||||
expect(urlSecure).toBe('wss://api.meshitrack.com/households/hh1/shopping-lists/list1/sync');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue