Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
NODE_ENV=development

DATABASE_URL=${DB_DRIVER}://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_SCHEMA}

DB_DRIVER=
DB_USER=
DB_SCHEMA=
DB_PASSWORD=
DB_HOST=
DB_PORT=
POSTGRES_USERNAME=postgres
POSTGRES_DATABASE=
POSTGRES_PASSWORD=
POSTGRES_HOST=
POSTGRES_PORT=5432

DATABASE_URL=postgresql://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE}

REDIS_HOST=
REDIS_PORT=6379
REDIS_USERNAME=default
REDIS_PASSWORD=

COOKIE_SECRET=

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback

GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_REDIRECT_URI=https://example.com/auth/google/callback

DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=https://example.com/auth/github/callback
Comment on lines +20 to +28
Comment on lines +24 to +28
3 changes: 3 additions & 0 deletions @types/i18next.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare namespace i18next {
export type TFunction = (key: string, options?: unknown) => string;
}
162 changes: 162 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions ci/Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM oven/bun:1
WORKDIR /usr/src/app

COPY package.json bun.lock ./
ENV CI=true
RUN bun install

COPY . .
RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" \
bunx --bun prisma generate

ENV NODE_ENV=development

USER bun
EXPOSE 8080/tcp
CMD [ "bun", "dev" ]
60 changes: 60 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: dojoh-api

services:
api:
container_name: dojoh-api
image: dojoh-api:latest
restart: unless-stopped
build:
dockerfile: ci/Dockerfile.dev
context: .
ports:
- "8080:8080"
env_file: .env
environment:
- NODE_ENV=development
networks:
- dojoh-net
volumes:
- ./:/usr/src/app
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
- pg
- redis

pg:
container_name: dojoh-pg
image: postgres:18
environment:
- POSTGRES_USER=dojoh
- POSTGRES_PASSWORD=my_secret_password
- POSTGRES_DB=dojoh_dev
ports:
- "5432:5432"
networks:
- dojoh-net
volumes:
- pg_data:/var/lib/postgresql/18/docker

redis:
container_name: dojoh-redis
image: redis:7
ports:
- "6379:6379"
environment:
- REDIS_PASSWORD=my_secret_password
command: >
sh -c "redis-server --requirepass $$REDIS_PASSWORD"
networks:
- dojoh-net

networks:
dojoh-net:
driver: bridge

volumes:
pg_data: {}
8 changes: 4 additions & 4 deletions config/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ export default {
postgres: {
host: env("POSTGRES_HOST", "localhost"),
port: env("POSTGRES_PORT", 5432),
username: env("POSTGRES_USERNAME", "postgres"),
password: env("POSTGRES_PASSWORD", "password"),
database: env("POSTGRES_DATABASE", "dojoh"),
username: env("POSTGRES_USERNAME", ""),
password: env("POSTGRES_PASSWORD", ""),
database: env("POSTGRES_DATABASE", ""),
},

redis: {
host: env("REDIS_HOST", "localhost"),
port: env("REDIS_PORT", 6379),
username: env("REDIS_USERNAME", "default"),
username: env("REDIS_USERNAME", ""),
password: env("REDIS_PASSWORD", ""),
},
};
6 changes: 4 additions & 2 deletions config/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import path from "node:path";

import type * as i18next from "i18next";

export default {
fallbackLng: "en-US",
supportedLngs: ["en-US", "es-ES", "fr-FR", "pt-BR"],
ns: ["messages", "errors"],
ns: ["messages", "errors", "zod"],
defaultNS: "messages",
backend: {
loadPath: path.join(process.cwd(), "locales/{{lng}}/{{ns}}.json"),
},
interpolation: {
escapeValue: false,
},
};
} satisfies i18next.InitOptions;
4 changes: 2 additions & 2 deletions config/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ export default {
secret: env("JWT_SECRET", "default_secret"),

access: {
expiresIn: Math.floor(Date.now() / 1000) + 60 * 60, // 1 hour in seconds
expiresIn: 60 * 60, // 1 hour in seconds
},

refresh: {
expiresIn: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days in seconds
expiresIn: 60 * 60 * 24, // 1 day in seconds
},
} satisfies JwtConfig;
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
CREATE TYPE "ImageVariantType" AS ENUM ('XS', 'S', 'M', 'L', 'XL');

-- CreateEnum
CREATE TYPE "OAuthProvider" AS ENUM ('GOOGLE', 'GITHUB');
CREATE TYPE "OAuthProvider" AS ENUM ('GOOGLE', 'GITHUB', 'DISCORD', 'NATIVE');

-- CreateTable
CREATE TABLE "Image" (
Expand Down Expand Up @@ -49,11 +49,12 @@ CREATE TABLE "User" (
-- CreateTable
CREATE TABLE "OAuthAccount" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"user_id" INTEGER,
"provider" "OAuthProvider" NOT NULL,
"provider_user_id" TEXT NOT NULL,
"provider_username" TEXT NOT NULL,
"provider_avatar_url" TEXT NOT NULL,
"provider_avatar_url" TEXT,
"provider_metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3),

Expand All @@ -66,6 +67,9 @@ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_nickname_key" ON "User"("nickname");

-- CreateIndex
CREATE UNIQUE INDEX "User_avatar_id_key" ON "User"("avatar_id");

-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");

Expand All @@ -82,7 +86,10 @@ CREATE UNIQUE INDEX "OAuthAccount_user_id_provider_key" ON "OAuthAccount"("user_
CREATE UNIQUE INDEX "OAuthAccount_provider_provider_user_id_key" ON "OAuthAccount"("provider", "provider_user_id");

-- AddForeignKey
ALTER TABLE "ImageVariant" ADD CONSTRAINT "ImageVariant_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "Image"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ImageVariant" ADD CONSTRAINT "ImageVariant_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_avatar_id_fkey" FOREIGN KEY ("avatar_id") REFERENCES "Image"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "OAuthAccount" ADD CONSTRAINT "OAuthAccount_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
39 changes: 24 additions & 15 deletions database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ model Image {
mime_type String
file_size Int

created_at DateTime @default(now())
ImageVariant ImageVariant[]
User User[]
created_at DateTime @default(now())

variants ImageVariant[]

owner User?
}

enum ImageVariantType {
Expand All @@ -29,7 +31,7 @@ enum ImageVariantType {

model ImageVariant {
id Int @id @default(autoincrement())
image Image @relation(fields: [image_id], references: [id])
image Image @relation(fields: [image_id], references: [id], onDelete: Cascade)
image_id Int

variant ImageVariantType
Expand All @@ -43,39 +45,46 @@ model ImageVariant {
}

model User {
id Int @id @default(autoincrement())
email String @unique
nickname String @unique
password String?
avatar Image? @relation(fields: [avatar_id], references: [id])
avatar_id Int?
id Int @id @default(autoincrement())
email String @unique
nickname String @unique
password String?

avatar Image? @relation(fields: [avatar_id], references: [id], onDelete: SetNull)
avatar_id Int? @unique

created_at DateTime @default(now())
updated_at DateTime? @updatedAt
deleted_at DateTime?

oauthAccounts OAuthAccount[]

@@index([email])
@@index([nickname])
@@index([deleted_at])
}

model OAuthAccount {
id Int @id @default(autoincrement())
user_id Int
id Int @id @default(autoincrement())
user_id Int? // Allow null for accounts that are not linked to a user yet
user User? @relation(fields: [user_id], references: [id], onDelete: SetNull)

provider OAuthProvider
provider_user_id String
provider_username String
provider_avatar_url String
provider_avatar_url String?
provider_metadata Json?

created_at DateTime @default(now())
updated_at DateTime? @updatedAt

@@unique([user_id, provider])
@@unique([provider, provider_user_id])
@@unique([user_id, provider], name: "user_provider_idx")
@@unique([provider, provider_user_id], name: "provider_user_id_idx")
}

enum OAuthProvider {
GOOGLE
GITHUB
DISCORD
NATIVE
}
9 changes: 5 additions & 4 deletions database/redis/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { RedisClient } from "bun";

import databaseConfig from "@/config/database";

const { redis } = databaseConfig;
const { redis: redisConfig } = databaseConfig;
const { host, port, username, password } = redisConfig;

const client = new RedisClient(
`redis://${redis.username}:${redis.password}@${redis.host}:${redis.port}`,
const redis = new RedisClient(
`redis://${username}:${password}@${host}:${port}`,
);

export default client;
export default redis;
8 changes: 8 additions & 0 deletions exceptions/conflict.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class ConflictError extends Error {
statusCode: number;
constructor(message: string) {
super(message);
this.name = "ConflictError";
this.statusCode = 409;
}
}
3 changes: 3 additions & 0 deletions exceptions/index.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./conflict.exception";
export * from "./notfound.exception";
export * from "./unauthorized.exception";
8 changes: 8 additions & 0 deletions exceptions/notfound.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class NotFoundError extends Error {
statusCode: number;
constructor(message: string) {
super(message);
this.name = "NotFoundError";
this.statusCode = 404;
}
}
8 changes: 8 additions & 0 deletions exceptions/unauthorized.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class UnauthorizedError extends Error {
statusCode: number;
constructor(message: string) {
super(message);
this.name = "UnauthorizedError";
this.statusCode = 401;
}
}
14 changes: 7 additions & 7 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import fastifyCookie from "@fastify/cookie";
import Fastify from "fastify";

import env from "./config/env";
import corsPlugin from "./plugins/cors";
import i18nPlugin from "./plugins/i18n";

const f = Fastify({
logger: true,
});

f.register(i18nPlugin);
f.register(corsPlugin);

f.get("/", async () => {
return { message: "Hello World!" };
f.register(fastifyCookie, {
secret: env("COOKIE_SECRET", undefined),
});

f.register(i18nPlugin);

f.get("/health", async () => {
return { status: "ok", timestamp: new Date().toISOString() };
});

f.post("/echo", async (request) => {
return { received: request.body };
});

// Register route modules
f.register(async (f) => {
const { default: version_1 } = await import("./routes/v1");
Expand Down
3 changes: 3 additions & 0 deletions locales/en-US/zod.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"nickname_rules": "The nickname must start and end with an alphanumeric character, and can contain dots, underscores, or hyphens in between. Consecutive dots, underscores, or hyphens are not allowed."
}
3 changes: 2 additions & 1 deletion locales/es-ES/errors.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"Invalid credentials": "Credenciales inválidas",
"Server error": "Error del servidor"
"Server error": "Error del servidor",
"Email already in use": "El correo electrónico ya está en uso"
}
3 changes: 3 additions & 0 deletions locales/es-ES/zod.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"nickname_rules": "El apodo debe comenzar y terminar con un carácter alfanumérico, y puede contener puntos, guiones bajos o guiones en el medio. No se permiten puntos, guiones bajos o guiones consecutivos."
}
1 change: 1 addition & 0 deletions locales/fr-FR/zod.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions locales/pt-BR/zod.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Loading
Loading