Skip to content

Commit bd5d9a1

Browse files
authored
Merge pull request #208 from zainfathoni/feature/rb-db
feat(db): update schema with NanoID, Content, Consumption, and AuditLog entities
2 parents 6ef01d6 + 1123bd2 commit bd5d9a1

23 files changed

Lines changed: 1145 additions & 43 deletions

File tree

app/components/__tests__/transaction-item.test.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
import { build, fake, oneOf } from '@jackfranklin/test-data-bot'
1+
import { build, fake, oneOf, perBuild } from '@jackfranklin/test-data-bot'
22
import { screen } from '@testing-library/react'
33
import { TransactionItem, TransactionItemProps } from '../transaction-item'
44
import { render } from '#test/test-utils'
55
import { printLocaleDateTimeString } from '~/utils/format'
66
import { TRANSACTION_STATUS } from '~/models/enum'
7+
import { generateId } from '~/utils/nanoid'
78

89
const transactionItemBuilder = build<TransactionItemProps>('TransactionItem', {
910
fields: {
10-
to: fake(
11-
(f) =>
12-
`${f.datatype.uuid()}?status=${oneOf(
13-
TRANSACTION_STATUS
14-
)}&page=${f.datatype.number()}`
11+
to: perBuild(
12+
() =>
13+
`${generateId()}?status=${oneOf(TRANSACTION_STATUS)}&page=${Math.floor(
14+
Math.random() * 100
15+
)}`
1516
),
1617
bankAccountName: fake((f) => f.finance.accountName()),
1718
bankAccountNumber: fake((f) => f.finance.account()),

app/models/__mocks__/audit-log.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { build, perBuild } from '@jackfranklin/test-data-bot'
2+
import { AuditLog } from '@prisma/client'
3+
import { AUDIT_ACTION, AUDIT_ENTITY_TYPE } from '../enum'
4+
import { generateId } from '../../utils/nanoid'
5+
6+
export const auditLogBuilder = build<Omit<AuditLog, 'id' | 'createdAt'>>({
7+
fields: {
8+
userId: null,
9+
action: perBuild(() => AUDIT_ACTION.CREATE),
10+
entityType: perBuild(() => AUDIT_ENTITY_TYPE.USER),
11+
entityId: perBuild(() => generateId()),
12+
oldValue: null,
13+
newValue: null,
14+
metadata: null,
15+
ipAddress: null,
16+
userAgent: null,
17+
},
18+
traits: {
19+
create: {
20+
overrides: {
21+
action: perBuild(() => AUDIT_ACTION.CREATE),
22+
oldValue: null,
23+
newValue: perBuild(() => JSON.stringify({ email: 'test@example.com' })),
24+
},
25+
},
26+
update: {
27+
overrides: {
28+
action: perBuild(() => AUDIT_ACTION.UPDATE),
29+
oldValue: perBuild(() => JSON.stringify({ status: 'SUBMITTED' })),
30+
newValue: perBuild(() => JSON.stringify({ status: 'VERIFIED' })),
31+
},
32+
},
33+
delete: {
34+
overrides: {
35+
action: perBuild(() => AUDIT_ACTION.DELETE),
36+
oldValue: perBuild(() => JSON.stringify({ id: 'deleted-entity' })),
37+
newValue: null,
38+
},
39+
},
40+
withUser: {
41+
overrides: {
42+
userId: perBuild(() => generateId()),
43+
},
44+
},
45+
withMetadata: {
46+
overrides: {
47+
ipAddress: perBuild(() => '127.0.0.1'),
48+
userAgent: perBuild(() => 'Mozilla/5.0 (compatible; test)'),
49+
metadata: perBuild(() => JSON.stringify({ source: 'test' })),
50+
},
51+
},
52+
},
53+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { build, perBuild } from '@jackfranklin/test-data-bot'
2+
import { Consumption } from '@prisma/client'
3+
import { CONSUMPTION_STATUS } from '../enum'
4+
5+
export const consumptionBuilder = build<
6+
Omit<
7+
Consumption,
8+
'id' | 'createdAt' | 'updatedAt' | 'userId' | 'contentId' | 'courseId'
9+
>
10+
>({
11+
fields: {
12+
progress: perBuild(() => 0),
13+
status: perBuild(() => CONSUMPTION_STATUS.PRISTINE),
14+
},
15+
traits: {
16+
pristine: {
17+
overrides: {
18+
progress: perBuild(() => 0),
19+
status: perBuild(() => CONSUMPTION_STATUS.PRISTINE),
20+
},
21+
},
22+
inProgress: {
23+
overrides: {
24+
progress: perBuild(() => 50),
25+
status: perBuild(() => CONSUMPTION_STATUS.IN_PROGRESS),
26+
},
27+
},
28+
completed: {
29+
overrides: {
30+
progress: perBuild(() => 100),
31+
status: perBuild(() => CONSUMPTION_STATUS.COMPLETED),
32+
},
33+
},
34+
},
35+
})

app/models/__mocks__/content.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { build, fake, perBuild } from '@jackfranklin/test-data-bot'
2+
import { Content } from '@prisma/client'
3+
import { CONTENT_TYPES } from '../enum'
4+
5+
export const contentBuilder = build<
6+
Omit<Content, 'id' | 'createdAt' | 'updatedAt' | 'authorId' | 'courseId'>
7+
>({
8+
fields: {
9+
slug: fake((f) => f.lorem.slug()),
10+
name: fake((f) => f.lorem.sentence()),
11+
description: fake((f) => f.lorem.paragraphs()),
12+
type: perBuild(() => CONTENT_TYPES.VIDEO),
13+
content: 'G3ZS8x86588', // YouTube video ID
14+
order: fake((f) => f.datatype.number({ min: 0, max: 100 })),
15+
},
16+
traits: {
17+
video: {
18+
overrides: {
19+
type: perBuild(() => CONTENT_TYPES.VIDEO),
20+
content: 'G3ZS8x86588',
21+
},
22+
},
23+
},
24+
})

app/models/enum.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,18 @@ export type TransactionStatus = 'SUBMITTED' | 'VERIFIED' | 'REJECTED'
3434
export const TRANSACTION_METHOD = {
3535
BANK_TRANSFER: 'BANK_TRANSFER',
3636
}
37+
38+
export const AUDIT_ACTION = {
39+
CREATE: 'CREATE',
40+
UPDATE: 'UPDATE',
41+
DELETE: 'DELETE',
42+
}
43+
44+
export const AUDIT_ENTITY_TYPE = {
45+
USER: 'USER',
46+
COURSE: 'COURSE',
47+
CONTENT: 'CONTENT',
48+
SUBSCRIPTION: 'SUBSCRIPTION',
49+
TRANSACTION: 'TRANSACTION',
50+
CONSUMPTION: 'CONSUMPTION',
51+
}

app/models/subscription.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Subscription } from '@prisma/client'
22
import { SUBSCRIPTION_STATUS } from './enum'
33
import { getFirstCourse } from './course'
44
import { db } from '~/utils/db.server'
5+
import { generateId } from '~/utils/nanoid'
56

67
type SubscriptionStatus = 'ACTIVE' | 'INACTIVE'
78

@@ -29,6 +30,7 @@ async function updateSubscription(
2930
} else {
3031
subscription = await db.subscription.create({
3132
data: {
33+
id: generateId(),
3234
userId,
3335
courseId: course.id,
3436
authorId: course.authorId,

app/models/user.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Subscription, User } from '@prisma/client'
22
import { db } from '~/utils/db.server'
3+
import { generateId } from '~/utils/nanoid'
34
import { ROLES } from '~/models/enum'
45

56
export type UserFields = Pick<
@@ -28,6 +29,7 @@ export async function updateUser(id: string, data: UserFields) {
2829
export async function createUserByEmail(email: string) {
2930
return await db.user.create({
3031
data: {
32+
id: generateId(),
3133
email,
3234
role: ROLES.MEMBER,
3335
},

app/routes/dashboard.purchase.confirm.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ import { XCircleIcon } from '@heroicons/react/solid'
1313
import { validateRequired } from '~/utils/validators'
1414
import { requireUser } from '~/services/auth.server'
1515
import { db } from '~/utils/db.server'
16+
import { generateId } from '~/utils/nanoid'
1617
import { getFirstCourse } from '~/models/course'
1718
import { getFirstTransaction } from '~/models/transaction'
1819
import { Button, Field } from '~/components/form-elements'
1920
import { TRANSACTION_STATUS } from '~/models/enum'
2021
import { Handle } from '~/utils/types'
2122

2223
interface TransactionFields {
24+
id?: string
2325
userId: string
2426
courseId: string
2527
authorId: string
@@ -103,7 +105,9 @@ export const action: ActionFunction = async ({ request }) => {
103105
data: fields,
104106
})
105107
} else {
106-
transaction = await db.transaction.create({ data: fields })
108+
transaction = await db.transaction.create({
109+
data: { ...fields, id: generateId() },
110+
})
107111
}
108112

109113
if (!transaction) {

app/utils/__tests__/nanoid.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { generateId, isValidNanoId, NANOID_LENGTH, nanoid } from '../nanoid'
3+
4+
describe('nanoid utilities', () => {
5+
describe('generateId', () => {
6+
it('should generate a string of correct length', () => {
7+
const id = generateId()
8+
expect(id).toHaveLength(NANOID_LENGTH)
9+
})
10+
11+
it('should generate unique IDs', () => {
12+
const ids = new Set<string>()
13+
const count = 1000
14+
15+
for (let i = 0; i < count; i++) {
16+
ids.add(generateId())
17+
}
18+
19+
// All IDs should be unique
20+
expect(ids.size).toBe(count)
21+
})
22+
23+
it('should only contain alphanumeric characters', () => {
24+
const id = generateId()
25+
expect(id).toMatch(/^[A-Za-z0-9]+$/)
26+
})
27+
28+
it('should not contain special characters like _ or -', () => {
29+
// Generate multiple IDs to increase confidence
30+
for (let i = 0; i < 100; i++) {
31+
const id = generateId()
32+
expect(id).not.toContain('_')
33+
expect(id).not.toContain('-')
34+
}
35+
})
36+
})
37+
38+
describe('nanoid', () => {
39+
it('should be a function that generates IDs', () => {
40+
const id = nanoid()
41+
expect(typeof id).toBe('string')
42+
expect(id).toHaveLength(NANOID_LENGTH)
43+
})
44+
})
45+
46+
describe('isValidNanoId', () => {
47+
it('should return true for valid NanoIDs', () => {
48+
const id = generateId()
49+
expect(isValidNanoId(id)).toBe(true)
50+
})
51+
52+
it('should return false for empty string', () => {
53+
expect(isValidNanoId('')).toBe(false)
54+
})
55+
56+
it('should return false for null or undefined', () => {
57+
expect(isValidNanoId(null as unknown as string)).toBe(false)
58+
expect(isValidNanoId(undefined as unknown as string)).toBe(false)
59+
})
60+
61+
it('should return false for wrong length', () => {
62+
expect(isValidNanoId('abc')).toBe(false)
63+
expect(isValidNanoId('a'.repeat(NANOID_LENGTH + 1))).toBe(false)
64+
expect(isValidNanoId('a'.repeat(NANOID_LENGTH - 1))).toBe(false)
65+
})
66+
67+
it('should return false for IDs with invalid characters', () => {
68+
expect(isValidNanoId('abc!@#$%^&*()')).toBe(false)
69+
expect(isValidNanoId('abc_defghijk')).toBe(false) // underscore not allowed
70+
expect(isValidNanoId('abc-defghijk')).toBe(false) // dash not allowed
71+
})
72+
73+
it('should return true for all valid characters', () => {
74+
// Test a valid 12-char string with only alphanumeric
75+
expect(isValidNanoId('aB1cD2eF3gH4')).toBe(true)
76+
expect(isValidNanoId('ABCDEFGHIJKL')).toBe(true)
77+
expect(isValidNanoId('123456789012')).toBe(true)
78+
expect(isValidNanoId('abcdefghijkl')).toBe(true)
79+
})
80+
81+
it('should return false for UUID format', () => {
82+
// UUIDs are 36 chars with dashes, should be invalid
83+
const uuid = '550e8400-e29b-41d4-a716-446655440000'
84+
expect(isValidNanoId(uuid)).toBe(false)
85+
})
86+
})
87+
88+
describe('NANOID_LENGTH', () => {
89+
it('should be 12', () => {
90+
expect(NANOID_LENGTH).toBe(12)
91+
})
92+
})
93+
})

app/utils/db.server.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PrismaClient } from '@prisma/client'
2+
import { generateId } from './nanoid'
23

34
let db: PrismaClient
45

@@ -7,15 +8,32 @@ declare global {
78
var __db: PrismaClient | undefined
89
}
910

11+
function createPrismaClient(): PrismaClient {
12+
const client = new PrismaClient()
13+
14+
// Middleware to auto-generate NanoID for models missing an id on create.
15+
// This serves as a safety net in case `id: generateId()` is forgotten.
16+
client.$use(async (params, next) => {
17+
if (params.action === 'create' && params.args?.data) {
18+
if (!params.args.data.id) {
19+
params.args.data.id = generateId()
20+
}
21+
}
22+
return next(params)
23+
})
24+
25+
return client
26+
}
27+
1028
// this is needed because in development we don't want to restart
1129
// the server with every change, but we want to make sure we don't
1230
// create a new connection to the DB with every change either.
1331
if (process.env.NODE_ENV === 'production') {
14-
db = new PrismaClient()
32+
db = createPrismaClient()
1533
db.$connect()
1634
} else {
1735
if (!global.__db) {
18-
global.__db = new PrismaClient()
36+
global.__db = createPrismaClient()
1937
global.__db.$connect()
2038
}
2139
db = global.__db

0 commit comments

Comments
 (0)