feat: initial commit

This commit is contained in:
2026-04-09 20:52:10 -03:00
commit 1a92cc6c11
86 changed files with 19533 additions and 0 deletions

17
src/lib/db/client.ts Normal file
View File

@@ -0,0 +1,17 @@
import { configAppDataSource } from '@/lib/db/data-source';
import { EntityTarget, ObjectLiteral } from 'typeorm';
export async function getDataSource() {
const dataSource = configAppDataSource();
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
return dataSource;
}
export async function getRepository<Entity extends ObjectLiteral>(
entity: EntityTarget<Entity>
) {
const dataSource = await getDataSource();
return dataSource.getRepository(entity);
}

View File

@@ -0,0 +1,15 @@
import * as dotenv from 'dotenv';
import { DataSource } from 'typeorm';
dotenv.config();
const AppDataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
synchronize: false,
logging: false,
entities: ['src/feature/**/*.entity.ts'],
migrations: ['migrations/*.ts'],
});
export default AppDataSource;

26
src/lib/db/data-source.ts Normal file
View File

@@ -0,0 +1,26 @@
import * as Entities from './entities';
import { resolve } from 'path';
import { DataSource } from 'typeorm';
let AppDataSource: DataSource | null = null;
const migrationsPath = resolve(__dirname, '../../../migrations/*.ts');
export const configAppDataSource = (url: string | undefined = undefined) => {
if (AppDataSource) {
return AppDataSource;
}
AppDataSource = new DataSource({
type: 'postgres',
url: url || process.env.DATABASE_URL,
synchronize: false,
logging: false,
entities: Object.values(Entities),
migrations: [migrationsPath],
});
return AppDataSource;
};
export default AppDataSource;

1
src/lib/db/entities.ts Normal file
View File

@@ -0,0 +1 @@
export { UserEntity } from '@/lib/feature/user/user.entity';

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const SessionClaims = z.object({
user: z.object({
email: z.email(),
username: z.string(),
full_name: z.string(),
image_url: z.string().optional(),
public_metadata: z.object({
role: z.enum(['admin', 'internal', 'user']).default('user'),
}),
}),
});
export type SessionClaims = z.infer<typeof SessionClaims>;

View File

@@ -0,0 +1,49 @@
import { UserRole, UserRoleValues } from '@/lib/feature/user/user.model';
import type { UUIDv4 } from '@/utils/types/uuid';
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryColumn({
type: 'bigint',
primary: true,
generated: true,
})
id: string;
@Column({ type: 'text' })
name: string;
@Column({ type: 'text', unique: true })
email: string;
@Column({
type: 'enum',
enum: UserRoleValues,
enumName: 'user_role',
default: UserRole.USER,
})
role: UserRole;
@Column({
name: 'created_at',
type: 'timestamp with time zone',
default: () => 'now()',
})
createdAt: Date;
@Column({
name: 'updated_at',
type: 'timestamp with time zone',
default: () => 'now()',
})
updatedAt: Date;
@Column({
name: 'external_id',
type: 'uuid',
unique: true,
default: () => 'gen_random_uuid()',
})
externalId: UUIDv4;
}

View File

@@ -0,0 +1,30 @@
'use server';
import { getRepository } from '@/lib/db/client';
import { UserEntity } from '@/lib/db/entities';
import { userEntityToModel } from '@/lib/feature/user/user.service';
import { getSessionData } from '@/lib/session/session-storage';
import { UUIDv4 } from '@/utils/types/uuid';
export const getUserByExternalId = async (externalId: UUIDv4) => {
const sessionData = await getSessionData();
if (
!sessionData ||
!sessionData.user ||
sessionData.user.externalId !== externalId
) {
throw new Error('User not permitted to access this data');
}
const userRepository = await getRepository(UserEntity);
const userEntity = await userRepository.findOne({
where: { externalId: externalId },
});
if (!userEntity) {
return null;
}
return userEntityToModel(userEntity);
};

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
export const UserRole = {
ADMIN: 'admin',
INTERNAL: 'internal',
USER: 'user',
} as const;
export type UserRole = (typeof UserRole)[keyof typeof UserRole];
export const UserRoleValues: UserRole[] = Object.values(UserRole);
export const CreateUserModel = z.object({
name: z.string(),
email: z.email(),
role: z.enum(UserRoleValues),
});
export type CreateUserModel = z.infer<typeof CreateUserModel>;
export const UpdateUserModel = z.object({
name: z.string().optional(),
email: z.email().optional(),
role: z.enum(UserRoleValues).optional(),
});
export type UpdateUserModel = z.infer<typeof UpdateUserModel>;
export const UserModel = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
active: z.boolean().optional(),
role: z.enum(UserRoleValues),
imageUrl: z.string().optional(),
externalId: z.uuid(),
});
export type UserModel = z.infer<typeof UserModel>;
export const UserInfoModel = z.object({
externalId: z.uuid(),
name: z.string(),
email: z.email(),
});
export type UserInfoModel = z.infer<typeof UserInfoModel>;

View File

@@ -0,0 +1,110 @@
import { getRepository } from '@/lib/db/client';
import { SessionClaims } from '@/lib/feature/user/clerk.model';
import { UserEntity } from '@/lib/feature/user/user.entity';
import {
CreateUserModel,
UpdateUserModel,
UserModel,
} from '@/lib/feature/user/user.model';
export const userEntityToModel = (userEntity: UserEntity): UserModel => {
return {
id: userEntity.id,
name: userEntity.name,
email: userEntity.email,
role: userEntity.role,
externalId: userEntity.externalId,
};
};
/**
* Retrieves a user by their email address.
* @param email - The email address of the user to retrieve.
* @returns {Promise<UserModel | null>} The user model if found, otherwise null.
*/
export const getUserByEmail = async (
email: string
): Promise<UserModel | null> => {
const userRepository = await getRepository(UserEntity);
const userEntity = await userRepository.findOneBy({ email });
if (!userEntity) {
return null;
}
return userEntityToModel(userEntity);
};
/**
* Saves a new user to the database.
* @param user - The user data to save.
* @returns {Promise<UserModel>} The saved user model.
* @throws {Error} If a user with the same email already exists.
*/
export const saveUser = async (user: CreateUserModel): Promise<UserModel> => {
const userRepository = await getRepository(UserEntity);
if (!!(await userRepository.findOneBy({ email: user.email }))) {
throw new Error(`User with email ${user.email} already exists`);
}
const newUser = userRepository.create(user);
return userEntityToModel(await userRepository.save(newUser));
};
/**
* Updates an existing user in the database.
* @param userId - The ID of the user to update.
* @param user - The new user data.
* @returns {Promise<UserModel>} The updated user model.
* @throws {Error} If the user with the given ID does not exist.
*/
export const updateUser = async (
userId: string,
user: UpdateUserModel
): Promise<UserModel> => {
const userRepository = await getRepository(UserEntity);
const existingUser = await userRepository.findOneBy({ id: userId });
if (!existingUser) {
throw new Error(`User with ID ${userId} not found`);
}
if (!!user.email) existingUser.email = user.email;
if (!!user.name) existingUser.name = user.name;
if (!!user.role) existingUser.role = user.role;
return userEntityToModel(await userRepository.save(existingUser));
};
/**
* Synchronizes a user with the database.
* If the user already exists, it skips saving and returns the existing user.
* If the user does not exist, it creates a new user record.
* @returns {Promise<UserModel>} The synchronized user model.
* @throws {Error} If the user email is not provided or if there is an issue
* saving the user.
* @param sessionClaims Session Claims from the Auth Provider
*/
export const syncUser = async (
sessionClaims: SessionClaims
): Promise<UserModel> => {
const { full_name, email } = sessionClaims.user;
const role = sessionClaims.user.public_metadata.role;
const existingUser = await getUserByEmail(email);
if (!existingUser) {
return await saveUser({
name: full_name,
email: email,
role: role,
});
}
return await updateUser(existingUser.id, {
name: full_name,
email: existingUser.email,
role: role,
});
};

View File

@@ -0,0 +1,9 @@
import { UserModel } from '@/lib/feature/user/user.model';
import { z } from 'zod';
export const SessionData = z.object({
user: UserModel.optional(),
});
export type SessionData = z.infer<typeof SessionData>;
export type SessionDataKey = keyof SessionData;
export type SessionDataValue = SessionData[SessionDataKey];

View File

@@ -0,0 +1,16 @@
import {
SessionDataKey,
SessionDataValue,
} from '@/lib/session/session-data.type';
import { getSession } from '@/lib/session/session-storage';
export const setSessionData = async (
key: SessionDataKey,
data: SessionDataValue
): Promise<void> => {
const session = await getSession();
Object.assign(session, {
[key]: data,
});
await session.save();
};

View File

@@ -0,0 +1,49 @@
'use server';
import { SessionData } from '@/lib/session/session-data.type';
import { siteConfig } from '@/site.config';
import { getIronSession, IronSession, SessionOptions } from 'iron-session';
import { cookies } from 'next/headers';
let sessionOptions: SessionOptions | undefined;
const getSessionOptions = (): SessionOptions => {
if (!!sessionOptions) return sessionOptions;
const password = process.env.SESSION_SECRET;
if (!password) {
throw new Error('SESSION_SECRET is not set in environment variables.');
}
sessionOptions = {
cookieName: siteConfig.slug,
password: password,
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
},
};
return sessionOptions;
};
export const getSession = async (): Promise<IronSession<SessionData>> => {
return await getIronSession(await cookies(), getSessionOptions());
};
export const clearSessionData = async (): Promise<void> => {
const session = await getSession();
session.destroy();
await session.save();
};
export const getSessionData = async (): Promise<SessionData | null> => {
try {
const session = await getSession();
return SessionData.parse(session);
} catch {
await clearSessionData();
}
return null;
};