eletrotupi / tcc/ commit / 6b62f4d

all: add an opt-in weekly resume preference

Settings had a weekly switch that was never saved; it now persists on the
user as weekly_resume_enabled and decides whether the Resumo Semanal card
shows at the top of Jornada. Nothing is computed or notified yet, so the flag
only gates display, and the weekly insight fan-out stays untouched so the
resume remains opt-in and separate from the notifications mute.
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago 6b62f4dcf96987b8b2e55bd210139a3ac9f0d976 Browse files
Parents 4d890b5
Changed files
11
Additions
+66
Deletions
-12
/dev/null → api/prisma/migrations/20261017011724_add_user_weekly_resume_pref/migration.sql
1 +
-- AlterTable
2 +
ALTER TABLE "users" ADD COLUMN     "weekly_resume_enabled" BOOLEAN NOT NULL DEFAULT false;
api/prisma/schema.prisma
1 1
generator client {
2 2
  provider = "prisma-client-js"
3 3
  //output   = "../src/generated/prisma"
4 4
}
5 5

25 25
  active                  Boolean   @default(false) @map("active")
26 26

27 27
  pushToken            String?  @map("push_token")
28 28
  notificationsEnabled Boolean  @default(true) @map("notifications_enabled")
29 29
  dailyReminderTime    String?  @map("daily_reminder_time")
30 +
  weeklyResumeEnabled  Boolean  @default(false) @map("weekly_resume_enabled")
30 31

31 32
  onboardingSeenAt DateTime? @map("onboarding_seen_at")
32 33
  termsAcceptedAt  DateTime? @map("terms_accepted_at")
33 34
  termsVersion     String?   @map("terms_version")
34 35

305 306
  THREE_TIMES_DAILY
306 307
  WEEKLY
307 308
  BIWEEKLY
308 309
  MONTHLY
309 310
}
api/src/lib/serializers/user.ts
1 1
import { Prisma, User } from '@prisma/client';
2 2

3 3
// DB-level projection of the allow-list below — keep the two in sync.
4 4
export const publicUserSelect = {
5 5
  id: true,
10 10
  avatarKey: true,
11 11
  avatarURL: true,
12 12
  active: true,
13 13
  notificationsEnabled: true,
14 14
  dailyReminderTime: true,
15 +
  weeklyResumeEnabled: true,
15 16
  onboardingSeenAt: true,
16 17
} satisfies Prisma.UserSelect;
17 18

18 19
// The only shape a user leaves the API in. Everything not listed here
19 20
// (encryptedPassword, reset secrets, activation code, pushToken) stays server-side.
26 27
  avatarKey: user.avatarKey,
27 28
  avatarURL: user.avatarURL,
28 29
  active: user.active,
29 30
  notificationsEnabled: user.notificationsEnabled,
30 31
  dailyReminderTime: user.dailyReminderTime,
32 +
  weeklyResumeEnabled: user.weeklyResumeEnabled,
31 33
  onboardingSeenAt: user.onboardingSeenAt,
32 34
});
api/src/schemas/user.schema.ts
1 1
import { z } from "zod";
2 2

3 3
export const CreateUserSchema = z.object({
4 4
  firstName: z.string(),
5 5
  lastName: z.string().optional(),
19 19
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
20 20

21 21
export const UpdateUserPreferencesSchema = z.object({
22 22
  notificationsEnabled: z.boolean().optional(),
23 23
  dailyReminderTime: z.string().regex(/^\d{2}:\d{2}$/).nullable().optional(),
24 +
  weeklyResumeEnabled: z.boolean().optional(),
24 25
});
25 26

26 27
export type UpdateUserPreferencesInput = z.infer<typeof UpdateUserPreferencesSchema>;
api/src/services/export/table.descriptors.ts
1 1
import {
2 2
  Activity,
3 3
  Appointment,
4 4
  CareAction,
5 5
  Insight,
46 46
    {
47 47
      header: "Notificações",
48 48
      value: (user) => booleanLabel(user.notificationsEnabled),
49 49
    },
50 50
    { header: "Lembrete diário", value: (user) => user.dailyReminderTime },
51 +
    {
52 +
      header: "Resumo semanal",
53 +
      value: (user) => booleanLabel(user.weeklyResumeEnabled),
54 +
    },
51 55
    { header: "Criado em", value: (user) => user.createdAt },
52 56
    { header: "Atualizado em", value: (user) => user.updatedAt },
53 57
  ],
54 58
};
55 59

202 206
    { header: "Início do período", value: (insight) => insight.periodStart },
203 207
    { header: "Fim do período", value: (insight) => insight.periodEnd },
204 208
    { header: "Gerado em", value: (insight) => insight.generatedAt },
205 209
  ],
206 210
};
api/tests/serializers/user.test.ts
1 1
import { describe, it, expect } from 'vitest'
2 2
import { User } from '@prisma/client'
3 3
import { publicUser } from '@app/lib/serializers/user'
4 4

5 5
const row = {
18 18
  activationCodeExpiresAt: new Date(),
19 19
  active: true,
20 20
  pushToken: 'device-token',
21 21
  notificationsEnabled: true,
22 22
  dailyReminderTime: '08:30',
23 +
  weeklyResumeEnabled: false,
23 24
  onboardingSeenAt: null,
24 25
  termsAcceptedAt: new Date(),
25 26
  termsVersion: '1',
26 27
} as User
27 28

30 31
    expect(publicUser(row)).toMatchObject({
31 32
      id: 7,
32 33
      email: 'pedro@example.com',
33 34
      firstName: 'Pedro',
34 35
      active: true,
36 +
      weeklyResumeEnabled: false,
35 37
      onboardingSeenAt: null,
36 38
    })
37 39
  })
38 40

39 41
  it('never carries a secret off the server', () => {
51 53
    ]) {
52 54
      expect(safe).not.toHaveProperty(field)
53 55
    }
54 56
  })
55 57
})
api/tests/users/me.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 3
import { createApp } from '@app/createApp'
4 4
import { buildPrisma, cleanupTestDb, createTestUser, generateTestToken, expectPublicUser } from '../test-helper'
5 5

19 19
      .set('Authorization', `Bearer ${token}`)
20 20

21 21
    expect(res.status).toBe(200)
22 22
    expect(res.body.user.email).toBe('me@example.com')
23 23
    expect(res.body.user.firstName).toBe('Pedro')
24 +
    expect(res.body.user.weeklyResumeEnabled).toBe(false)
24 25
    expectPublicUser(res.body.user)
25 26
  })
26 27

27 28
  it('returns 401 without token', async () => {
28 29
    const res = await request(app).get('/users/me')
29 30
    expect(res.status).toBe(401)
30 31
  })
31 32
})
api/tests/users/preferences.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 3
import { createApp } from '@app/createApp'
4 4
import { DailyReminderJobName } from '@app/lib/queue/types'
5 5
import {
47 47
        data: { userId: user.id },
48 48
      })
49 49
    )
50 50
  })
51 51

52 +
  it('updates the weekly resume preference', async () => {
53 +
    const user = await createTestUser(db, { email: 'prefs@example.com' })
54 +
    const token = generateTestToken(user.id, user.email)
55 +

56 +
    const res = await request(app)
57 +
      .patch('/users/me')
58 +
      .set('Authorization', `Bearer ${token}`)
59 +
      .send({ user: { weeklyResumeEnabled: true } })
60 +

61 +
    expect(res.status).toBe(200)
62 +
    expect(res.body.user.weeklyResumeEnabled).toBe(true)
63 +
    expectPublicUser(res.body.user)
64 +

65 +
    const dbUser = await db.user.findUnique({ where: { id: user.id } })
66 +
    expect(dbUser!.weeklyResumeEnabled).toBe(true)
67 +
  })
68 +

69 +
  it('returns 400 for a non-boolean weeklyResumeEnabled', async () => {
70 +
    const user = await createTestUser(db, { email: 'prefs@example.com' })
71 +
    const token = generateTestToken(user.id, user.email)
72 +

73 +
    const res = await request(app)
74 +
      .patch('/users/me')
75 +
      .set('Authorization', `Bearer ${token}`)
76 +
      .send({ user: { weeklyResumeEnabled: 'yes' } })
77 +

78 +
    expect(res.status).toBe(400)
79 +
  })
80 +

52 81
  it('accepts a null reminder time and schedules nothing', async () => {
53 82
    const user = await createTestUser(db, { email: 'prefs@example.com' })
54 83
    const token = generateTestToken(user.id, user.email)
55 84

56 85
    const res = await request(app)
94 123
      .send({ user: { notificationsEnabled: false } })
95 124

96 125
    expect(res.status).toBe(401)
97 126
  })
98 127
})
frontend/app/(tabs)/insights.tsx
1 1
import { ScreenLayout } from "@/components/ui/ScreenLayout";
2 2
import { useAuth } from "@/context/AuthContext";
3 3
import { Section, SectionHeader } from "@/components/ui/Sections";
4 4
import { WeeklyResume } from "@/components/insights/WeeklyResume";
5 5
import { MoodTrendWidget } from "@/components/insights/MoodTrendWidget";
13 13
    <ScreenLayout
14 14
      userName={user?.firstName}
15 15
      userAvatar={user?.avatarURL}
16 16
      showNotificationBadge={true}
17 17
    >
18 -
      <WeeklyResume
19 -
        avgMood="Neutro"
20 -
        stats={[
21 -
          { label: "Média de Sono", value: "7.2h" },
22 -
          { label: "Humor Estável", value: "92%" },
23 -
        ]}
24 -
      />
18 +
      {user?.weeklyResumeEnabled && (
19 +
        <WeeklyResume
20 +
          avgMood="Neutro"
21 +
          stats={[
22 +
            { label: "Média de Sono", value: "7.2h" },
23 +
            { label: "Humor Estável", value: "92%" },
24 +
          ]}
25 +
        />
26 +
      )}
25 27

26 28
      <Section>
27 29
        <SectionHeader title="Indicadores" subtitle="Baseado nos últimos 7 dias" />
28 30

29 31
        <EnergySleepWidget />
33 35
        <TriggerPatternWidget />
34 36
      </Section>
35 37
    </ScreenLayout>
36 38
  );
37 39
}
frontend/app/(tabs)/settings.tsx
1 1
import {
2 2
  TouchableOpacity,
3 3
  StyleSheet,
4 4
  View,
5 5
  Switch,
21 21
import { useToast } from '@/context/ToastContext';
22 22
import { apiClient } from "@/lib/api";
23 23

24 24
export default function Settings() {
25 25
  const { user, logout, updateAuthUser } = useAuth();
26 -
  const [allowWeeklyInsights, setAllowWeeklyInsights] = useState(false);
27 26
  const [logoutVisible, setLogoutVisible] = useState(false);
28 27
  const [exportVisible, setExportVisible] = useState(false);
29 28
  const [exporting, setExporting] = useState(false);
30 29
  const { showToast } = useToast();
31 30

32 31
  const notificationsEnabled = user?.notificationsEnabled ?? true;
33 32
  const dailyReminderTime = user?.dailyReminderTime ?? "20:00";
33 +
  const weeklyResumeEnabled = user?.weeklyResumeEnabled ?? false;
34 34

35 35
  const patchUserMe = usePatchUserMe((updatedUser) => {
36 36
    updateAuthUser(updatedUser);
37 37
  });
38 38

39 39
  const handleNotificationToggle = (value: boolean) => {
40 40
    patchUserMe.mutate({ notificationsEnabled: value });
41 +
  };
42 +

43 +
  const handleWeeklyResumeToggle = (value: boolean) => {
44 +
    patchUserMe.mutate({ weeklyResumeEnabled: value });
41 45
  };
42 46

43 47
  const handleTimeChange = (date: Date) => {
44 48
    const time = `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
45 49
    patchUserMe.mutate({ dailyReminderTime: time });
202 206
                justifyContent: "space-between",
203 207
                marginLeft: 12,
204 208
                flex: 1,
205 209
              }}
206 210
            >
207 -
              <Text style={styles.rowLabel}>Indicadores semanais</Text>
208 -
              <Text style={styles.rowSubtitle}>Relatórios de progressos</Text>
211 +
              <Text style={styles.rowLabel}>Resumo semanal</Text>
212 +
              <Text style={styles.rowSubtitle}>
213 +
                Mostra um resumo no topo da Jornada, a partir dos seus dados.
214 +
                Desativar não afeta os indicadores semanais.
215 +
              </Text>
209 216
            </View>
210 217

211 218
            <Switch
212 -
              onValueChange={setAllowWeeklyInsights}
213 -
              value={allowWeeklyInsights}
219 +
              onValueChange={handleWeeklyResumeToggle}
220 +
              value={weeklyResumeEnabled}
214 221
              thumbColor={Colors.light.tint}
215 222
              trackColor={{
216 223
                false: Colors.light.disabled,
217 224
                true: Colors.light.gray,
218 225
              }}
226 +
              disabled={patchUserMe.isPending}
219 227
            />
220 228
          </View>
221 229
        </Card>
222 230
      </Section>
223 231

394 402
    height: StyleSheet.hairlineWidth,
395 403
    backgroundColor: "#D1D5DB",
396 404
    marginVertical: 16,
397 405
  },
398 406
});
frontend/lib/api/types.ts
1 1
export interface User {
2 2
  id: number;
3 3
  email: string;
4 4
  firstName: string;
5 5
  lastName?: string;
7 7
  avatarKey?: string;
8 8
  avatarURL?: string;
9 9
  active: boolean;
10 10
  notificationsEnabled?: boolean;
11 11
  dailyReminderTime?: string;
12 +
  weeklyResumeEnabled?: boolean;
12 13
  onboardingSeenAt?: Date | null;
13 14
}
14 15

15 16
export interface AvatarResponse {
16 17
  message: string;
63 64
}
64 65

65 66
export interface UserPreferencesPayload {
66 67
  notificationsEnabled?: boolean;
67 68
  dailyReminderTime?: string | null;
69 +
  weeklyResumeEnabled?: boolean;
68 70
}
69 71

70 72
export interface ExportDataResponse {
71 73
  message: string;
72 74
}
388 390
  moodId?: number;
389 391
}
390 392
export interface MedicineRegimenResponse { regimen: MedicineRegimen; }
391 393

392 394
export interface LinkMoodPayload { moodId: number; perceivedImpact: number; }