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 filesParents
ff12ba9Changed files
7
Additions
+143
Deletions
-24
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 +
})