diff --git a/apps/api/package.json b/apps/api/package.json index c2a024f..4bc7f35 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,15 +7,17 @@ }, "dependencies": { "@boring.tools/database": "workspace:*", - "@boring.tools/schema": "workspace:*", "@boring.tools/logger": "workspace:*", + "@boring.tools/schema": "workspace:*", "@hono/clerk-auth": "^2.0.0", "@hono/zod-openapi": "^0.16.2", "@scalar/hono-api-reference": "^0.5.149", "hono": "^4.6.3", + "semver": "^7.6.3", "svix": "^1.36.0" }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "@types/semver": "^7.5.8" } } diff --git a/apps/api/src/changelog/version/byId.ts b/apps/api/src/changelog/version/byId.ts new file mode 100644 index 0000000..ef8351f --- /dev/null +++ b/apps/api/src/changelog/version/byId.ts @@ -0,0 +1,66 @@ +import { changelog, changelog_version, db } from '@boring.tools/database' +import { VersionByIdParams, VersionOutput } from '@boring.tools/schema' +import { createRoute } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' + +export const byId = createRoute({ + method: 'get', + path: '/:id', + request: { + params: VersionByIdParams, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: VersionOutput, + }, + }, + description: 'Return version by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const byIdFunc = async ({ + userId, + id, +}: { + userId: string + id: string +}) => { + const versionResult = await db.query.changelog_version.findFirst({ + where: eq(changelog_version.id, id), + with: { + commits: true, + }, + }) + + if (!versionResult) { + return null + } + + if (!versionResult.changelogId) { + return null + } + + const changelogResult = await db.query.changelog.findMany({ + where: and(eq(changelog.userId, userId)), + columns: { + id: true, + }, + }) + + const changelogIds = changelogResult.map((cl) => cl.id) + + if (!changelogIds.includes(versionResult.changelogId)) { + return null + } + + return versionResult +} diff --git a/apps/api/src/changelog/version/create.ts b/apps/api/src/changelog/version/create.ts new file mode 100644 index 0000000..f873f66 --- /dev/null +++ b/apps/api/src/changelog/version/create.ts @@ -0,0 +1,88 @@ +import { changelog, changelog_version, db } from '@boring.tools/database' +import { VersionCreateInput, VersionCreateOutput } from '@boring.tools/schema' +import { createRoute, type z } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' +import semver from 'semver' + +export const create = createRoute({ + method: 'post', + path: '/', + request: { + body: { + content: { + 'application/json': { schema: VersionCreateInput }, + }, + }, + }, + responses: { + 201: { + content: { + 'application/json': { schema: VersionCreateOutput }, + }, + description: 'Version created', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const createFunc = async ({ + userId, + payload, +}: { + userId: string + payload: z.infer +}) => { + const formattedVersion = semver.coerce(payload.version) + const validVersion = semver.valid(formattedVersion) + + const changelogResult = await db.query.changelog.findFirst({ + where: and( + eq(changelog.userId, userId), + eq(changelog.id, payload.changelogId), + ), + }) + + if (!changelogResult) { + throw new HTTPException(404, { + message: 'changelog not found', + }) + } + + if (validVersion === null) { + throw new HTTPException(409, { + message: 'Version is not semver compatible', + }) + } + + // Check if a version with the same version already exists + const versionResult = await db.query.version.findFirst({ + where: and( + eq(changelog_version.changelogId, payload.changelogId), + eq(changelog_version.version, validVersion), + ), + }) + + if (versionResult) { + throw new HTTPException(409, { + message: 'Version exists already', + }) + } + + const [versionCreateResult] = await db + .insert(changelog_version) + .values({ + changelogId: payload.changelogId, + version: validVersion, + status: payload.status, + markdown: payload.markdown, + }) + .returning() + + return versionCreateResult +} diff --git a/apps/api/src/changelog/version/delete.ts b/apps/api/src/changelog/version/delete.ts new file mode 100644 index 0000000..d3af99f --- /dev/null +++ b/apps/api/src/changelog/version/delete.ts @@ -0,0 +1,60 @@ +import { changelog, changelog_version, db } from '@boring.tools/database' +import { GeneralOutput } from '@boring.tools/schema' +import { createRoute } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' + +export const remove = createRoute({ + method: 'delete', + path: '/:id', + responses: { + 200: { + content: { + 'application/json': { + schema: GeneralOutput, + }, + }, + description: 'Removes a version by id', + }, + 404: { + content: { + 'application/json': { + schema: GeneralOutput, + }, + }, + description: 'Version not found', + }, + }, +}) + +export const removeFunc = async ({ + userId, + id, +}: { + userId: string + id: string +}) => { + const changelogResult = await db.query.changelog.findMany({ + where: and(eq(changelog.userId, userId)), + with: { + versions: { + where: eq(changelog_version.id, id), + }, + }, + }) + + const findChangelog = changelogResult.find((change) => + change.versions.find((ver) => ver.id === id), + ) + + if (!findChangelog?.versions.length) { + throw new HTTPException(404, { + message: 'Version not found', + }) + } + + return db + .delete(changelog_version) + .where(and(eq(changelog_version.id, id))) + .returning() +} diff --git a/apps/api/src/changelog/version/index.ts b/apps/api/src/changelog/version/index.ts new file mode 100644 index 0000000..f807be3 --- /dev/null +++ b/apps/api/src/changelog/version/index.ts @@ -0,0 +1,101 @@ +import { OpenAPIHono } from '@hono/zod-openapi' + +import { HTTPException } from 'hono/http-exception' +import type { Variables } from '../..' +import { verifyAuthentication } from '../../utils/authentication' +import { byId, byIdFunc } from './byId' +import { create, createFunc } from './create' +import { remove, removeFunc } from './delete' +import { update, updateFunc } from './update' + +const app = new OpenAPIHono<{ Variables: Variables }>() + +app.openapi(create, async (c) => { + const userId = verifyAuthentication(c) + try { + const payload = await c.req.json() + const result = await createFunc({ userId, payload }) + + if (!result) { + return c.json({ message: 'Version not created' }, 400) + } + + return c.json(result, 201) + } catch (error) { + if (error instanceof HTTPException) { + return c.json({ message: error.message }, error.status) + } + return c.json({ message: 'An unexpected error occurred' }, 500) + } +}) + +app.openapi(byId, async (c) => { + const userId = verifyAuthentication(c) + try { + const id = c.req.param('id') + const result = await byIdFunc({ userId, id }) + + if (!result) { + return c.json({ message: 'Version not found' }, 404) + } + + // Ensure all required properties are present and non-null + return c.json( + { + ...result, + changelogId: result.changelogId || '', + version: result.version || '', + status: result.status || 'draft', + releasedAt: result.releasedAt + ? result.releasedAt.toISOString() + : new Date().toISOString(), + commits: result.commits || [], + markdown: result.markdown || '', + }, + 200, + ) + } catch (error) { + if (error instanceof HTTPException) { + return c.json({ message: error.message }, error.status) + } + return c.json({ message: 'An unexpected error occurred' }, 500) + } +}) + +app.openapi(update, async (c) => { + const userId = verifyAuthentication(c) + try { + const id = c.req.param('id') + + if (!id) { + return c.json({ message: 'Version not found' }, 404) + } + + const result = await updateFunc({ + userId, + payload: await c.req.json(), + id, + }) + + return c.json(result) + } catch (error) { + if (error instanceof HTTPException) { + return c.json({ message: error.message }, error.status) + } + return c.json({ message: 'An unexpected error occurred' }, 500) + } +}) + +app.openapi(remove, async (c) => { + const userId = verifyAuthentication(c) + const id = c.req.param('id') + const result = await removeFunc({ userId, id }) + + if (result.length === 0) { + return c.json({ message: 'Version not found' }, 404) + } + + return c.json({ message: 'Version removed' }) +}) + +export default app diff --git a/apps/api/src/changelog/version/update.ts b/apps/api/src/changelog/version/update.ts new file mode 100644 index 0000000..5822c1a --- /dev/null +++ b/apps/api/src/changelog/version/update.ts @@ -0,0 +1,72 @@ +import { changelog, changelog_version, db } from '@boring.tools/database' +import { VersionUpdateInput, VersionUpdateOutput } from '@boring.tools/schema' +import { createRoute, type z } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' + +export const update = createRoute({ + method: 'put', + path: '/:id', + request: { + body: { + content: { + 'application/json': { schema: VersionUpdateInput }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { schema: VersionUpdateOutput }, + }, + description: 'Return updated version', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const updateFunc = async ({ + userId, + payload, + id, +}: { + userId: string + payload: z.infer + id: string +}) => { + const changelogResult = await db.query.changelog.findMany({ + where: and(eq(changelog.userId, userId)), + with: { + versions: { + where: eq(changelog_version.id, id), + }, + }, + }) + + const findChangelog = changelogResult.find((change) => + change.versions.find((ver) => ver.id === id), + ) + + if (!findChangelog?.versions.length) { + throw new HTTPException(404, { + message: 'Version not found', + }) + } + + const [versionUpdateResult] = await db + .update(changelog_version) + .set({ + status: payload.status, + markdown: payload.markdown, + releasedAt: payload.status === 'published' ? new Date() : null, + }) + .where(and(eq(changelog_version.id, id))) + .returning() + + return versionUpdateResult +} diff --git a/apps/api/src/changelog/version/version.test.ts b/apps/api/src/changelog/version/version.test.ts new file mode 100644 index 0000000..f6e9fae --- /dev/null +++ b/apps/api/src/changelog/version/version.test.ts @@ -0,0 +1,200 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { + type ChangelogSelect, + type CommitSelect, + type UserSelect, + type VersionSelect, + changelog, + commit, + db, + user, +} from '@changelog/database' +import type { + CommitCreateInput, + VersionCreateInput, + VersionOutput, + VersionUpdateInput, +} from '@changelog/schemas' +import type { z } from '@hono/zod-openapi' +import { eq } from 'drizzle-orm' +import { fetch } from '../../utils/testing/fetch' + +describe('Version', () => { + let testUser: UserSelect + let testChangelog: ChangelogSelect + let testCommits: CommitSelect[] + let testVersion: VersionSelect + + beforeAll(async () => { + const tUser = await db + .insert(user) + .values({ email: 'version@test.local' }) + .returning() + const tChangelog = await db + .insert(changelog) + .values({ + title: 'test', + description: 'some description', + userId: tUser[0].id, + }) + .returning() + + const payload: z.infer = [ + { + changelogId: tChangelog[0].id, + date: new Date(), + message: 'Some commit', + shortHash: '1234567', + body: 'some body', + }, + { + changelogId: tChangelog[0].id, + date: new Date(), + message: 'Some other commit', + shortHash: '1234568', + body: 'some body', + }, + ] + + await fetch( + { + path: '/api/commit', + method: 'POST', + body: payload, + }, + tUser[0], + ) + + testCommits = await db.query.commit.findMany({ + where: eq(commit.changelogId, tChangelog[0].id), + }) + testUser = tUser[0] + testChangelog = tChangelog[0] + }) + + afterAll(async () => { + await db.delete(user).where(eq(user.email, 'version@test.local')) + }) + + describe('Create', () => { + test('Success', async () => { + const payload: z.infer = { + changelogId: testChangelog.id, + releasedAt: new Date(), + commits: testCommits.map((c) => c.shortHash), + status: 'draft', + version: '1.0.0', + markdown: '', + } + + const res = await fetch( + { + path: '/api/version', + method: 'POST', + body: payload, + }, + testUser, + ) + + const json = await res.json() + testVersion = json + + expect(res.status).toBe(201) + }) + + test('Duplicate', async () => { + const payload: z.infer = { + changelogId: testChangelog.id, + releasedAt: new Date(), + commits: testCommits.map((c) => c.shortHash), + status: 'draft', + version: '1.0.0', + markdown: '', + } + + const res = await fetch( + { + path: '/api/version', + method: 'POST', + body: payload, + }, + testUser, + ) + expect(res.status).toBe(409) + }) + }) + + describe('By Id', () => { + test('Success', async () => { + const res = await fetch( + { + path: `/api/version/${testVersion.id}`, + method: 'GET', + }, + testUser, + ) + + expect(res.status).toBe(200) + + const json: z.infer = await res.json() + expect(json.commits).toHaveLength(2) + }) + + test('Not found', async () => { + const res = await fetch( + { + path: '/api/version/a7d2a68b-0696-4424-96c9-3629ae37978c', + method: 'GET', + }, + testUser, + ) + + expect(res.status).toBe(404) + }) + }) + + describe('Update', () => { + test('Success', async () => { + const payload: z.infer = { + status: 'published', + markdown: '', + } + const res = await fetch( + { + path: `/api/version/${testVersion.id}`, + method: 'PUT', + body: payload, + }, + testUser, + ) + + expect(res.status).toBe(200) + }) + }) + + describe('Remove', () => { + test('Success', async () => { + const res = await fetch( + { + path: `/api/version/${testVersion.id}`, + method: 'DELETE', + }, + testUser, + ) + + expect(res.status).toBe(200) + }) + + test('Not Found', async () => { + const res = await fetch( + { + path: `/api/version/${testVersion.id}`, + method: 'DELETE', + }, + testUser, + ) + + expect(res.status).toBe(404) + }) + }) +}) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3825cfc..dad3a54 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,6 +4,7 @@ import { apiReference } from '@scalar/hono-api-reference' import { cors } from 'hono/cors' import changelog from './changelog' +import version from './changelog/version' import user from './user' import { authentication } from './utils/authentication' @@ -22,6 +23,7 @@ app.use('/v1/*', authentication) app.route('/v1/user', user) app.route('/v1/changelog', changelog) +app.route('/v1/changelog/version', version) app.doc('/openapi.json', { openapi: '3.0.0', diff --git a/bun.lockb b/bun.lockb index b1ce076..c99871e 100755 Binary files a/bun.lockb and b/bun.lockb differ