eletrotupi / tcc/ commit / b36ca93

api/insights: the insights read can ask for a specific day

The read endpoint now takes an optional day and filters on the insight's
period start, and an unqualified daily request resolves to the server's
app-timezone today

Previously a daily request returned whichever DAILY_* row was
regenerated last, so a day with nothing logged still showed yesterday's
numbers under the today's comparision heading
Pedro Lucas Porcellis porcellis@eletrotupi.com · 12 days ago b36ca93521cd90a436cca219f26ba8cb906448f4 Browse files
Parents de0605f
Changed files
5
Additions
+189
Deletions
-10
api/src/controllers/insights.ts
1 1
import { Response, NextFunction } from "express";
2 2
import { AuthenticatedRequest } from "@app/middleware/auth";
3 +
import { InsightType } from "@prisma/client";
3 4
import { getInsightsByUserId } from "@app/services/insight.service";
5 +
import { parseAppDay } from "@app/lib/queue/processors/insights/utils";
4 6
import { InsightsQuerySchema } from "@app/schemas";
5 7
import { formatValidationError } from "@app/lib/errors/validationError";
6 8

9 +
const DAILY_INSIGHT_TYPES = new Set<InsightType>([
10 +
  InsightType.DAILY_ENERGY,
11 +
  InsightType.DAILY_SLEEP,
12 +
]);
13 +

14 +
// A day with no explicit date means "today" for the daily insight types, so the
15 +
// client never computes a day boundary; other types stay unfiltered
16 +
function resolveInsightDay(
17 +
  type?: InsightType,
18 +
  day?: string,
19 +
): Date | undefined {
20 +
  if (day) return parseAppDay(day);
21 +

22 +
  return type && DAILY_INSIGHT_TYPES.has(type) ? new Date() : undefined;
23 +
}
24 +

7 25
export const InsightsController = {
8 26
  index: async (
9 27
    req: AuthenticatedRequest,
10 28
    res: Response,
11 29
    next: NextFunction,
15 33

16 34
      if (!parsedQuery.success) {
17 35
        return res.status(400).json(formatValidationError(parsedQuery.error));
18 36
      }
19 37

38 +
      const { type, period, limit, day } = parsedQuery.data;
39 +

20 40
      const insights = await getInsightsByUserId(req.userId!, {
21 -
        type: parsedQuery.data.type,
22 -
        period: parsedQuery.data.period,
23 -
        limit: parsedQuery.data.limit,
41 +
        type,
42 +
        period,
43 +
        limit,
44 +
        day: resolveInsightDay(type, day),
24 45
      });
25 46

26 47
      return res.status(200).json({ insights });
27 48
    } catch (err) {
28 49
      next(err);
29 50
    }
30 51
  },
31 52
};
api/src/lib/queue/processors/insights/utils.ts
1 1
import { tz } from "@date-fns/tz";
2 -
import { addDays, startOfDay, subDays } from "date-fns";
2 +
import { addDays, parse, startOfDay, subDays } from "date-fns";
3 3

4 4
export function rollingPeriod(days = 7) {
5 5
  const periodEnd = new Date();
6 6
  const periodStart = new Date();
7 7
  periodStart.setDate(periodEnd.getDate() - days);
26 26
// Start of the app-timezone day immediately before the one containing `date`
27 27
export function previousAppDay(date: Date = new Date()): Date {
28 28
  return startOfDay(subDays(date, 1, { in: APP_TIMEZONE }), {
29 29
    in: APP_TIMEZONE,
30 30
  });
31 +
}
32 +

33 +
// Start of the app-timezone day named by a YYYY-MM-DD string
34 +
export function parseAppDay(day: string): Date {
35 +
  return parse(day, "yyyy-MM-dd", new Date(), { in: APP_TIMEZONE });
31 36
}
32 37

33 38
// The app-timezone day containing `date`, as a half-open [start, end) window
34 39
export function appDayWindow(date: Date = new Date()): {
35 40
  start: Date;
49 54
} {
50 55
  const { start, end } = appDayWindow(date);
51 56

52 57
  return { periodStart: start.toISOString(), periodEnd: end.toISOString() };
53 58
}
api/src/schemas/insight.schema.ts
1 1
import { z } from 'zod';
2 +
import { isMatch } from 'date-fns';
2 3
import { InsightPeriod, InsightType } from '@prisma/client';
3 4

4 5
export const InsightsQuerySchema = z.object({
5 6
  type: z.nativeEnum(InsightType).optional(),
6 7
  period: z.nativeEnum(InsightPeriod).optional(),
7 8
  limit: z.coerce.number().int().positive().max(100).optional(),
9 +
  day: z
10 +
    .string()
11 +
    .regex(/^\d{4}-\d{2}-\d{2}$/)
12 +
    .refine((value) => isMatch(value, 'yyyy-MM-dd'))
13 +
    .optional(),
8 14
});
9 15

10 16
export type InsightsQueryInput = z.infer<typeof InsightsQuerySchema>;
api/src/services/insight.service.ts
1 1
import { prisma } from "@app/lib/prisma";
2 -
import { Insight, InsightType, InsightPeriod } from "@prisma/client";
2 +
import { appDayWindow } from "@app/lib/queue/processors/insights/utils";
3 +
import { Prisma, Insight, InsightType, InsightPeriod } from "@prisma/client";
3 4

4 5
export const getInsightsByUserId = async (
5 6
  userId: number,
6 7
  params?: {
7 8
    type?: InsightType;
8 9
    period?: InsightPeriod;
9 10
    limit?: number;
11 +
    day?: Date;
10 12
  },
11 13
): Promise<Insight[]> => {
14 +
  const where: Prisma.InsightWhereInput = {
15 +
    userId,
16 +
    ...(params?.type && { type: params.type }),
17 +
    ...(params?.period && { period: params.period }),
18 +
  };
19 +

20 +
  if (params?.day) {
21 +
    const { start, end } = appDayWindow(params.day);
22 +
    where.periodStart = { gte: start, lt: end };
23 +
  }
24 +

12 25
  return prisma.insight.findMany({
13 -
    where: {
14 -
      userId,
15 -
      ...(params?.type && { type: params.type }),
16 -
      ...(params?.period && { period: params.period }),
17 -
    },
26 +
    where,
18 27
    orderBy: { generatedAt: "desc" },
19 28
    take: params?.limit ?? 10,
20 29
  });
21 30
};
22 31

27 36
  return prisma.insight.findFirst({
28 37
    where: { userId, type },
29 38
    orderBy: { generatedAt: "desc" },
30 39
  });
31 40
};
api/tests/insights/index.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 +
import { format } from 'date-fns'
4 +
import { tz } from '@date-fns/tz'
3 5
import { createApp } from '@app/createApp'
4 6
import { InsightPeriod, InsightType } from '@prisma/client'
7 +
import { appDayWindow } from '@app/lib/queue/processors/insights/utils'
5 8
import {
6 9
  buildPrisma,
7 10
  cleanupTestDb,
8 11
  createTestUser,
9 12
  generateTestToken,
10 13
} from '../test-helper'
11 14

12 15
const app = createApp()
13 16
const db = buildPrisma()
17 +
const SP = tz('America/Sao_Paulo')
14 18

15 19
afterAll(() => db.$disconnect())
16 20
beforeEach(() => cleanupTestDb(db))
21 +

22 +
// The YYYY-MM-DD key the read endpoint expects for the app day containing date
23 +
function appDayKey(date: Date) {
24 +
  return format(date, 'yyyy-MM-dd', { in: SP })
25 +
}
17 26

18 27
let seedCounter = 0
19 28
// The schema is unique on (userId, type, periodStart), so give every seeded
20 29
// row a distinct periodStart unless the test asks for a specific one
21 30
function uniquePeriodStart() {
160 169
  })
161 170

162 171
  it('returns 401 without a token', async () => {
163 172
    const res = await request(app).get('/insights')
164 173
    expect(res.status).toBe(401)
174 +
  })
175 +

176 +
  it('resolves an unqualified daily request to today', async () => {
177 +
    const user = await createTestUser(db)
178 +
    const token = generateTestToken(user.id, user.email)
179 +
    const { start: today } = appDayWindow()
180 +
    const { start: yesterday } = appDayWindow(new Date(today.getTime() - 1))
181 +

182 +
    await seedInsight(user.id, {
183 +
      type: InsightType.DAILY_ENERGY,
184 +
      period: InsightPeriod.DAILY,
185 +
      periodStart: yesterday,
186 +
      title: 'energy-yesterday',
187 +
    })
188 +
    await seedInsight(user.id, {
189 +
      type: InsightType.DAILY_ENERGY,
190 +
      period: InsightPeriod.DAILY,
191 +
      periodStart: today,
192 +
      title: 'energy-today',
193 +
    })
194 +
    await seedInsight(user.id, {
195 +
      type: InsightType.DAILY_SLEEP,
196 +
      period: InsightPeriod.DAILY,
197 +
      periodStart: yesterday,
198 +
      title: 'sleep-yesterday',
199 +
    })
200 +
    await seedInsight(user.id, {
201 +
      type: InsightType.DAILY_SLEEP,
202 +
      period: InsightPeriod.DAILY,
203 +
      periodStart: today,
204 +
      title: 'sleep-today',
205 +
    })
206 +

207 +
    const energy = await request(app)
208 +
      .get('/insights?type=DAILY_ENERGY')
209 +
      .set('Authorization', `Bearer ${token}`)
210 +
    const sleep = await request(app)
211 +
      .get('/insights?type=DAILY_SLEEP')
212 +
      .set('Authorization', `Bearer ${token}`)
213 +

214 +
    expect(energy.status).toBe(200)
215 +
    expect(energy.body.insights.map((i: any) => i.title)).toEqual([
216 +
      'energy-today',
217 +
    ])
218 +
    expect(sleep.status).toBe(200)
219 +
    expect(sleep.body.insights.map((i: any) => i.title)).toEqual(['sleep-today'])
220 +
  })
221 +

222 +
  it('filters the insight to an explicit day', async () => {
223 +
    const user = await createTestUser(db)
224 +
    const token = generateTestToken(user.id, user.email)
225 +
    const { start: today } = appDayWindow()
226 +
    const { start: yesterday } = appDayWindow(new Date(today.getTime() - 1))
227 +

228 +
    await seedInsight(user.id, {
229 +
      type: InsightType.DAILY_ENERGY,
230 +
      period: InsightPeriod.DAILY,
231 +
      periodStart: yesterday,
232 +
      title: 'energy-yesterday',
233 +
    })
234 +
    await seedInsight(user.id, {
235 +
      type: InsightType.DAILY_ENERGY,
236 +
      period: InsightPeriod.DAILY,
237 +
      periodStart: today,
238 +
      title: 'energy-today',
239 +
    })
240 +

241 +
    const res = await request(app)
242 +
      .get(`/insights?type=DAILY_ENERGY&day=${appDayKey(yesterday)}`)
243 +
      .set('Authorization', `Bearer ${token}`)
244 +

245 +
    expect(res.status).toBe(200)
246 +
    expect(res.body.insights.map((i: any) => i.title)).toEqual([
247 +
      'energy-yesterday',
248 +
    ])
249 +
  })
250 +

251 +
  it('returns an empty list for a day with no generated insight', async () => {
252 +
    const user = await createTestUser(db)
253 +
    const token = generateTestToken(user.id, user.email)
254 +
    const { start: today } = appDayWindow()
255 +
    const { start: yesterday } = appDayWindow(new Date(today.getTime() - 1))
256 +

257 +
    await seedInsight(user.id, {
258 +
      type: InsightType.DAILY_ENERGY,
259 +
      period: InsightPeriod.DAILY,
260 +
      periodStart: today,
261 +
      title: 'energy-today',
262 +
    })
263 +

264 +
    const res = await request(app)
265 +
      .get(`/insights?type=DAILY_ENERGY&day=${appDayKey(yesterday)}`)
266 +
      .set('Authorization', `Bearer ${token}`)
267 +

268 +
    expect(res.status).toBe(200)
269 +
    expect(res.body.insights).toEqual([])
270 +
  })
271 +

272 +
  it('returns an empty list when today has no daily insight', async () => {
273 +
    const user = await createTestUser(db)
274 +
    const token = generateTestToken(user.id, user.email)
275 +
    const { start: today } = appDayWindow()
276 +
    const { start: yesterday } = appDayWindow(new Date(today.getTime() - 1))
277 +

278 +
    await seedInsight(user.id, {
279 +
      type: InsightType.DAILY_ENERGY,
280 +
      period: InsightPeriod.DAILY,
281 +
      periodStart: yesterday,
282 +
      title: 'energy-yesterday',
283 +
    })
284 +

285 +
    const res = await request(app)
286 +
      .get('/insights?type=DAILY_ENERGY')
287 +
      .set('Authorization', `Bearer ${token}`)
288 +

289 +
    expect(res.status).toBe(200)
290 +
    expect(res.body.insights).toEqual([])
291 +
  })
292 +

293 +
  it('returns 400 for a malformed day', async () => {
294 +
    const user = await createTestUser(db)
295 +
    const token = generateTestToken(user.id, user.email)
296 +

297 +
    const res = await request(app)
298 +
      .get('/insights?day=2026-13-40')
299 +
      .set('Authorization', `Bearer ${token}`)
300 +

301 +
    expect(res.status).toBe(400)
302 +
    expect(res.body.fields).toBeTruthy()
165 303
  })
166 304
})