eletrotupi / tcc/ commit / d056c17

api/users: record terms acceptance and onboarding

Signup now requires an explicit acceptance and stamps which version was
agreed to, and a new endpoint marks the walkthrough as seen once. That
is what the client gates on
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago d056c177d3c92fbe121ee526d7bab29f7ba377eb Browse files
Parents ff12ba9
Changed files
7
Additions
+143
Deletions
-24
api/scripts/seed-dev.mjs modified+79 -16
api/scripts/seed-dev.mjs
1 1
import pg from 'pg'
2 2
import bcrypt from 'bcryptjs'
3 3

4 4
const { Client } = pg
5 5

6 6
const dbUrl = process.env.DATABASE_URL
7 -
const email = process.env.SEED_EMAIL ?? 'e2e@example.com'
8 -
const password = process.env.SEED_PASSWORD ?? 'e2e-password'
9 -

10 7
if (!dbUrl) {
11 8
  console.error('DATABASE_URL is not set')
12 9
  process.exit(1)
13 10
}
14 11

12 +
const DEFAULT_EMAIL = process.env.SEED_EMAIL ?? 'e2e@example.com'
13 +
const DEFAULT_PASSWORD = process.env.SEED_PASSWORD ?? 'e2e-password'
14 +

15 +
// One user per lifecycle state, so the manual verification checklist can jump
16 +
// straight to a state instead of re-running signup -> email -> code every time.
17 +
const users = [
18 +
  {
19 +
    firstName: 'E2E',
20 +
    lastName: 'User',
21 +
    email: DEFAULT_EMAIL,
22 +
    password: DEFAULT_PASSWORD,
23 +
    active: true,
24 +
    onboardingSeen: true,
25 +
    activationCode: null,
26 +
  },
27 +
  {
28 +
    firstName: 'Inactive',
29 +
    lastName: 'User',
30 +
    email: 'inactive@example.com',
31 +
    password: 'inactive-password',
32 +
    active: false,
33 +
    onboardingSeen: false,
34 +
    activationCode: '123456',
35 +
  },
36 +
  {
37 +
    firstName: 'Onboarding',
38 +
    lastName: 'User',
39 +
    email: 'onboarding@example.com',
40 +
    password: 'onboarding-password',
41 +
    active: true,
42 +
    onboardingSeen: false,
43 +
    activationCode: null,
44 +
  },
45 +
]
46 +

15 47
const db = new Client({ connectionString: dbUrl })
16 48
await db.connect()
17 49

18 -
const hash = bcrypt.hashSync(password, 10)
19 -
const { rows } = await db.query(
20 -
  `INSERT INTO users (first_name, last_name, email, encrypted_password, active, updated_at)
21 -
   VALUES ($1, $2, $3, $4, true, NOW())
22 -
   ON CONFLICT (email) DO UPDATE
23 -
     SET encrypted_password = EXCLUDED.encrypted_password,
24 -
         active = true,
25 -
         updated_at = NOW()
26 -
   RETURNING id, email`,
27 -
  ['E2E', 'User', email, hash],
28 -
)
50 +
for (const user of users) {
51 +
  const hash = bcrypt.hashSync(user.password, 10)
52 +

53 +
  // The seeded activation code stays valid for a year so the inactive fixture
54 +
  // does not silently rot between runs.
55 +
  const codeExpiresAt = user.activationCode
56 +
    ? new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
57 +
    : null
58 +

59 +
  const { rows } = await db.query(
60 +
    `INSERT INTO users (
61 +
       first_name, last_name, email, encrypted_password, active,
62 +
       activation_code, activation_code_expires_at,
63 +
       onboarding_seen_at, terms_accepted_at, terms_version, updated_at
64 +
     )
65 +
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), '1', NOW())
66 +
     ON CONFLICT (email) DO UPDATE
67 +
       SET encrypted_password = EXCLUDED.encrypted_password,
68 +
           active = EXCLUDED.active,
69 +
           activation_code = EXCLUDED.activation_code,
70 +
           activation_code_expires_at = EXCLUDED.activation_code_expires_at,
71 +
           onboarding_seen_at = EXCLUDED.onboarding_seen_at,
72 +
           terms_accepted_at = NOW(),
73 +
           terms_version = '1',
74 +
           updated_at = NOW()
75 +
     RETURNING id, email`,
76 +
    [
77 +
      user.firstName,
78 +
      user.lastName,
79 +
      user.email,
80 +
      hash,
81 +
      user.active,
82 +
      user.activationCode,
83 +
      codeExpiresAt,
84 +
      user.onboardingSeen ? new Date() : null,
85 +
    ],
86 +
  )
87 +

88 +
  const suffix = user.activationCode ? ` code=${user.activationCode}` : ''
89 +
  console.log(
90 +
    `Seeded ${rows[0].email} (id ${rows[0].id}) ` +
91 +
      `active=${user.active} onboardingSeen=${user.onboardingSeen}${suffix}`,
92 +
  )
93 +
}
29 94

30 95
await db.end()
31 -

32 -
console.log(`Seeded user: ${rows[0].email} (id ${rows[0].id})`)
/dev/null → api/src/lib/constants.ts
1 +
export const CURRENT_TERMS_VERSION = '1';
api/src/routes/users.ts
1 1
import { Router } from "express";
2 2
import { UsersController } from '@app/controllers/users';
3 3
import multer from '@app/services/avatar';
4 4
import { requireAuth } from '@app/middleware/auth';
5 5

6 6
const router = Router();
7 7

8 8
router.post('/', UsersController.create);
9 9
router.get('/me', requireAuth, UsersController.me);
10 10
router.patch('/me', requireAuth, UsersController.updatePreferences);
11 +
router.post('/me/onboarding/complete', requireAuth, UsersController.completeOnboarding);
11 12
router.put('/:id', requireAuth, UsersController.update);
12 13
router.post('/me/avatar', requireAuth, multer.single('avatar'), UsersController.updateAvatar);
13 14
router.delete('/me/avatar', requireAuth, UsersController.removeAvatar);
14 15

15 16
export default router;
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(),
6 6
  email: z.string().email(),
7 7
  password: z.string().min(6),
8 8
  pushToken: z.string().optional(),
9 +
  acceptedTerms: z.literal(true),
9 10
});
10 11

11 -
export const UpdateUserSchema = CreateUserSchema.partial().extend({
12 -
  id: z.number(),
13 -
});
12 +
export const UpdateUserSchema = CreateUserSchema.omit({ acceptedTerms: true })
13 +
  .partial()
14 +
  .extend({
15 +
    id: z.number(),
16 +
  });
14 17

15 18
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
16 19
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
17 20

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

23 26
export type UpdateUserPreferencesInput = z.infer<typeof UpdateUserPreferencesSchema>;
api/src/services/user.service.ts
1 1
import { prisma } from '@app/lib/prisma';
2 2
import { User } from '@prisma/client';
3 3
import bcrypt from 'bcryptjs';
4 4

5 5
import {
6 6
  CreateUserInput,
7 7
  UpdateUserInput
8 8
} from "@app/schemas";
9 9

10 +
import { CURRENT_TERMS_VERSION } from '@app/lib/constants';
11 +

10 12
export const createUser = async(input: CreateUserInput): Promise<User> => {
11 -
  const { password, ...data } = input;
13 +
  const { password, acceptedTerms, ...data } = input;
12 14
  const encryptedPassword = bcrypt.hashSync(password, 10);
13 15

14 16
  return await prisma.user.create({
15 17
    data: {
16 -
      ...data, encryptedPassword
18 +
      ...data,
19 +
      encryptedPassword,
20 +
      termsAcceptedAt: acceptedTerms ? new Date() : undefined,
21 +
      termsVersion: acceptedTerms ? CURRENT_TERMS_VERSION : undefined,
17 22
    }
18 23
  })
19 24
};
20 25

21 26
export const findUserById = async(id: number): Promise<User | null> => {
66 71
        not: null
67 72
      }
68 73
    }
69 74
  });
70 75
}
api/tests/users/create.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 -
import { buildPrisma, cleanupTestDb } from '../test-helper'
4 +
import { buildPrisma, cleanupTestDb, expectPublicUser } from '../test-helper'
5 5
import { recordedJobs, resetQueueMocks } from '../mocks/queue'
6 6

7 7
const app = createApp()
8 8
const db = buildPrisma()
9 9

16 16

17 17
describe('POST /users', () => {
18 18
  it('creates a user and returns a JWT token', async () => {
19 19
    const res = await request(app)
20 20
      .post('/users')
21 -
      .send({ user: { email: 'new@example.com', firstName: 'Novo', password: 'senha123' } })
21 +
      .send({ user: { email: 'new@example.com', firstName: 'Novo', password: 'senha123', acceptedTerms: true } })
22 22

23 23
    expect(res.status).toBe(201)
24 24
    expect(res.body.token).toBeTruthy()
25 +
    expect(res.body.user.email).toBe('new@example.com')
26 +
    expect(res.body.user.active).toBe(false)
27 +
    expectPublicUser(res.body.user)
25 28

26 29
    const dbUser = await db.user.findUnique({ where: { email: 'new@example.com' } })
27 30
    expect(dbUser).not.toBeNull()
28 31
    expect(dbUser!.firstName).toBe('Novo')
29 32
    expect(dbUser!.activationCode).toMatch(/^\d{6}$/)
33 +
    expect(dbUser!.termsAcceptedAt).not.toBeNull()
34 +
    expect(dbUser!.termsVersion).toBe('1')
30 35

31 36
    expect(recordedJobs).toContainEqual(
32 37
      expect.objectContaining({
33 38
        queue: 'mail',
34 39
        name: 'mail:welcome',
42 47
      data: { email: 'dup@example.com', firstName: 'A', encryptedPassword: 'x' },
43 48
    })
44 49

45 50
    const res = await request(app)
46 51
      .post('/users')
47 -
      .send({ user: { email: 'dup@example.com', firstName: 'B', password: 'senha123' } })
52 +
      .send({ user: { email: 'dup@example.com', firstName: 'B', password: 'senha123', acceptedTerms: true } })
48 53

49 54
    expect(res.status).toBe(409)
50 55
  })
51 56

52 57
  it('returns 400 for missing required fields', async () => {
57 62
    expect(res.status).toBe(400)
58 63
    expect(res.body.error).toBeTruthy()
59 64
    expect(res.body.fields).toBeTruthy()
60 65
  })
61 66
})
/dev/null → api/tests/users/onboarding.test.ts
1 +
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 +
import request from 'supertest'
3 +
import { createApp } from '@app/createApp'
4 +
import {
5 +
  buildPrisma,
6 +
  cleanupTestDb,
7 +
  createTestUser,
8 +
  generateTestToken,
9 +
  expectPublicUser,
10 +
} from '../test-helper'
11 +

12 +
const app = createApp()
13 +
const db = buildPrisma()
14 +

15 +
afterAll(() => db.$disconnect())
16 +
beforeEach(() => cleanupTestDb(db))
17 +

18 +
describe('POST /users/me/onboarding/complete', () => {
19 +
  it('records the completion and returns the public user', async () => {
20 +
    const user = await createTestUser(db, { email: 'onboarding@example.com' })
21 +
    const token = generateTestToken(user.id, user.email)
22 +

23 +
    expect(user.onboardingSeenAt).toBeNull()
24 +

25 +
    const res = await request(app)
26 +
      .post('/users/me/onboarding/complete')
27 +
      .set('Authorization', `Bearer ${token}`)
28 +

29 +
    expect(res.status).toBe(200)
30 +
    expect(res.body.user.onboardingSeenAt).not.toBeNull()
31 +
    expectPublicUser(res.body.user)
32 +

33 +
    const dbUser = await db.user.findUnique({ where: { id: user.id } })
34 +
    expect(dbUser!.onboardingSeenAt).not.toBeNull()
35 +
  })
36 +

37 +
  it('returns 401 without a token', async () => {
38 +
    const res = await request(app).post('/users/me/onboarding/complete')
39 +
    expect(res.status).toBe(401)
40 +
  })
41 +
})