diff --git a/.gitignore b/.gitignore index 3de52dfd3..cbf72f8e5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +**/node_modules /.pnp .pnp.js .yarn/install-state.gz diff --git a/bun.lockb b/bun.lockb index b336bd996..46d48f93b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index d6e535f79..0c2d0122c 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,8 @@ "@types/node": "^20", "typescript": "^5" }, - "workspaces": ["packages/*"] + "workspaces": ["packages/*"], + "dependencies": { + "viem": "^2.7.3" + } } diff --git a/packages/example/src/context/admin/action/authenticate.ts b/packages/example/src/context/admin/action/authenticate.ts new file mode 100644 index 000000000..85e2ff3f9 --- /dev/null +++ b/packages/example/src/context/admin/action/authenticate.ts @@ -0,0 +1,55 @@ +"use server"; + +import type { AdminSession } from "@/type/AdminSession"; +import { type SessionOptions, getIronSession } from "iron-session"; +import { cookies } from "next/headers"; + +/** + * Options used to store the session in the cookies + */ +const sessionOptions: SessionOptions = { + password: process.env.SESSION_ENCRYPTION_KEY ?? "", + cookieName: "admin-session", + ttl: 60 * 60 * 24, // 1 day + cookieOptions: { + secure: true, + }, +}; + +/** + * Get the full session from the cookies + */ +async function getFullSession() { + return await getIronSession(cookies(), sessionOptions); +} + +/** + * Check if the current user is admin + * - Admin role is used to add articles + */ +export async function isAdmin() { + const currentSession = await getFullSession(); + return currentSession.isAdmin ?? false; +} + +/** + * Try to login as an admin + * @param password + */ +export async function login(password: string) { + if (password !== process.env.ADMIN_PASSWORD) { + throw new Error("Invalid password"); + } + + const session = await getFullSession(); + session.isAdmin = true; + await session.save(); +} + +/** + * Delete the current session + */ +export async function deleteSession() { + const session = await getFullSession(); + session.destroy(); +} diff --git a/packages/example/src/context/article/dto/ArticleDocument.ts b/packages/example/src/context/article/dto/ArticleDocument.ts new file mode 100644 index 000000000..a5f2b456e --- /dev/null +++ b/packages/example/src/context/article/dto/ArticleDocument.ts @@ -0,0 +1,14 @@ +import type { Hex } from "viem"; + +export type ArticleDocument = Readonly<{ + // The id of the article (an hex string representing a byte32 hash) + _id: Hex; + // The content id linked to this article + contentId: Hex; + // The title of the article + title: string; + // The description of the article + description?: string; + // The link to access this article + link: string; +}>; diff --git a/packages/example/src/context/article/dto/ContentDocument.ts b/packages/example/src/context/article/dto/ContentDocument.ts new file mode 100644 index 000000000..6fb47e26d --- /dev/null +++ b/packages/example/src/context/article/dto/ContentDocument.ts @@ -0,0 +1,8 @@ +import type { Hex } from "viem"; + +export type ContentDocument = Readonly<{ + // The id of the content (an hex string, representing a bigint) + _id: Hex; + // The title of the content + title: string; +}>; diff --git a/packages/example/src/context/article/repository/ArticleRepository.ts b/packages/example/src/context/article/repository/ArticleRepository.ts new file mode 100644 index 000000000..b694e5513 --- /dev/null +++ b/packages/example/src/context/article/repository/ArticleRepository.ts @@ -0,0 +1,52 @@ +import type { ArticleDocument } from "@/context/article/dto/ArticleDocument"; +import { DI } from "@/context/common/di"; +import { getMongoDb } from "@/context/common/mongoDb"; +import type { Collection, WithId } from "mongodb"; +import type { Hex } from "viem"; + +/** + * Repository used to access the article collection + */ +export class ArticleRepository { + constructor(private readonly collection: Collection) {} + + /** + * Get an article by its id + * @param id + */ + public async getById( + id: Hex + ): Promise | undefined> { + return this.collection.findOne({ _id: id }); + } + + /** + * Get multiple article by their ids + * @param ids + */ + public async getByIds(ids: Hex[]): Promise[]> { + return this.collection.find({ _id: { $in: ids } }).toArray(); + } + + /** + * Create a new article + */ + public async create(article: ArticleDocument) { + const insertResult = await this.collection.insertOne(article); + if (!insertResult.acknowledged) { + throw new Error("Failed to insert test content"); + } + return insertResult.insertedId; + } +} + +export const getArticleRepository = DI.registerAndExposeGetter({ + id: "ArticleRepository", + isAsync: true, + getter: async () => { + const db = await getMongoDb(); + return new ArticleRepository( + db.collection("articles") + ); + }, +}); diff --git a/packages/example/src/context/article/repository/ContentRepository.ts b/packages/example/src/context/article/repository/ContentRepository.ts new file mode 100644 index 000000000..f0261ea31 --- /dev/null +++ b/packages/example/src/context/article/repository/ContentRepository.ts @@ -0,0 +1,35 @@ +import type { ContentDocument } from "@/context/article/dto/ContentDocument"; +import { DI } from "@/context/common/di"; +import { getMongoDb } from "@/context/common/mongoDb"; +import type { Collection } from "mongodb"; +import type { Hex } from "viem"; + +/** + * Repository used to access the content collection + */ +export class ContentRepository { + constructor(private readonly collection: Collection) {} + + /** + * Get a content by its id + * @param id + */ + public async getById(id: Hex) { + const content = await this.collection.findOne({ _id: id }); + if (!content) { + throw new Error(`Content ${id} not found`); + } + return content; + } +} + +export const getContentRepository = DI.registerAndExposeGetter({ + id: "ContentRepository", + isAsync: true, + getter: async () => { + const db = await getMongoDb(); + return new ContentRepository( + db.collection("contents") + ); + }, +}); diff --git a/packages/example/src/context/common/di.ts b/packages/example/src/context/common/di.ts new file mode 100644 index 000000000..dffa87d13 --- /dev/null +++ b/packages/example/src/context/common/di.ts @@ -0,0 +1,134 @@ +type ServiceIdentifier = string; +type ServiceGetter = () => T | Promise; +type ServiceEntry = { + getter: ServiceGetter; + instance?: T; +}; + +type ConditionalServiceGetter< + Return, + Getter extends ServiceGetter = ServiceGetter, +> = ReturnType extends Promise + ? () => Promise + : () => Return; + +/** + * Our service register + */ +const registry = new Map>(); + +/** + * Function to register a new service + */ +const register = (id: ServiceIdentifier, getter: ServiceGetter) => { + if (registry.has(id)) { + throw new Error(`Service ${id} already registered.`); + } + + registry.set(id, { getter }); +}; + +/** + * Register a new dependency in our DI Container and return a direct getter + * @param p + */ +function registerAndExposeGetter(p: { + id: ServiceIdentifier; + getter: () => ValueType; + isAsync?: false; +}): () => ValueType; + +/** + * Register a new dependency in our DI Container and return an async getter + * @param p + */ +function registerAndExposeGetter(p: { + id: ServiceIdentifier; + getter: () => Promise; + isAsync: true; +}): () => Promise; + +/** + * Register a new dependency in our DI Container + * @param id + * @param getter + * @param isAsync + */ +function registerAndExposeGetter< + ValueType, + BuilderType extends ServiceGetter = ServiceGetter, + GetterType extends ConditionalServiceGetter< + ValueType, + BuilderType + > = ConditionalServiceGetter, +>({ + id, + getter, + isAsync, +}: { + id: ServiceIdentifier; + getter: ServiceGetter; + isAsync?: boolean; +}): ConditionalServiceGetter { + register(id, getter); + return ( + isAsync ? () => getAsync(id) : () => get(id) + ) as GetterType; +} + +/** + * Get a dependency from our DI Container + * @param id + */ +async function getAsync(id: ServiceIdentifier): Promise { + const entry = registry.get(id); + + // If we don't have an instance, and no getter, throw + if (!entry) { + throw new Error("Service not found."); + } + + let { instance } = entry; + + // If we don't have an instance yet, try to build it + if (!instance) { + instance = await entry.getter(); + registry.set(id, { getter: entry.getter, instance }); + } + return instance as T; +} + +/** + * Get a dependency from our DI Container + * @param id + */ +function get(id: ServiceIdentifier): T { + const entry = registry.get(id); + + // If we don't have an instance, and no getter, throw + if (!entry) { + throw new Error("Service not found."); + } + + let { instance } = entry; + + // If we don't have an instance yet, try to build it + if (!instance) { + instance = entry.getter(); + if (instance instanceof Promise) { + throw new Error( + "Service needs to be built asynchronously. Use getAsync to build and get the service." + ); + } + registry.set(id, { getter: entry.getter, instance }); + } + + return instance as T; +} + +/** + * Our DI Container + */ +export const DI = { + registerAndExposeGetter, +}; diff --git a/packages/example/src/context/common/mongoDb.ts b/packages/example/src/context/common/mongoDb.ts new file mode 100644 index 000000000..f5835c959 --- /dev/null +++ b/packages/example/src/context/common/mongoDb.ts @@ -0,0 +1,21 @@ +"use server"; + +import { DI } from "@/context/common/di"; +import { MongoClient } from "mongodb"; + +// Get the mongo db client +export const getMongoDb = DI.registerAndExposeGetter({ + id: "Mongo", + isAsync: true, + getter: async () => { + // Get the mongo client + // TODO: Should use Config.MONGODB_FRAK_POC_URI instead, but next isn't happy about it + const client = new MongoClient( + process.env.MONGODB_FRAK_POC_URI as string + ); + // Connect to the database + await client.connect(); + // and then connect to the poc database + return client.db("poc"); + }, +}); diff --git a/packages/example/src/type/AdminSession.ts b/packages/example/src/type/AdminSession.ts new file mode 100644 index 000000000..9236f5b69 --- /dev/null +++ b/packages/example/src/type/AdminSession.ts @@ -0,0 +1,3 @@ +export type AdminSession = { + isAdmin: true; +};