diff --git a/README.md b/README.md index 355288e..0191a7a 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,13 @@ For the mock data, we always want up to date tables, so after you have done the You can do this with the pg_dump command in the postgres container: ```bash -npm run backup +npm run overwrite-mockup-data +``` + +Note: you might have to change the db container name in the script. Eg on mac with podman its with underscores instead of dashes which makes the command: + +```bash +docker exec -it badgehub-api_db_1 /usr/bin/pg_dump --username badgehub --schema badgehub badgehub > mockup-data.sql ``` #### Run the down migration to test it. diff --git a/migrations/sqls/20241116085102-initialize-up.sql b/migrations/sqls/20241116085102-initialize-up.sql index 1ffdabb..346c53e 100644 --- a/migrations/sqls/20241116085102-initialize-up.sql +++ b/migrations/sqls/20241116085102-initialize-up.sql @@ -1,9 +1,12 @@ -- back up with old schema alter schema badgehub rename to badgehub_old; create schema badgehub; -create table badgehub.migrations -( - like badgehub_old.migrations including all + +-- Recfreate badgehub.migrations +CREATE TABLE badgehub.migrations ( + id serial NOT NULL, + name character varying(255) NOT NULL, + run_on timestamp without time zone NOT NULL ); -- create tables @@ -19,7 +22,7 @@ create table badges create table users ( - id text primary key, + id serial primary key, email text unique, admin boolean, name text not null, @@ -40,7 +43,7 @@ create table projects updated_at timestamptz not null default now(), deleted_at timestamptz, version_id integer, - user_id text not null, + user_id integer not null, slug text not null primary key, git text, allow_team_fixes boolean, diff --git a/mockup-data.sql b/mockup-data.sql index 7d183d1..968d047 100644 --- a/mockup-data.sql +++ b/mockup-data.sql @@ -192,7 +192,7 @@ CREATE TABLE badgehub.projects ( updated_at timestamp with time zone DEFAULT now() NOT NULL, deleted_at timestamp with time zone, version_id integer, - user_id text NOT NULL, + user_id integer NOT NULL, slug text NOT NULL, git text, allow_team_fixes boolean @@ -206,7 +206,7 @@ ALTER TABLE badgehub.projects OWNER TO badgehub; -- CREATE TABLE badgehub.users ( - id text NOT NULL, + id integer NOT NULL, email text, admin boolean, name text NOT NULL, @@ -224,6 +224,28 @@ CREATE TABLE badgehub.users ( ALTER TABLE badgehub.users OWNER TO badgehub; +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: badgehub; Owner: badgehub +-- + +CREATE SEQUENCE badgehub.users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE badgehub.users_id_seq OWNER TO badgehub; + +-- +-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: badgehub; Owner: badgehub +-- + +ALTER SEQUENCE badgehub.users_id_seq OWNED BY badgehub.users.id; + + -- -- Name: versioned_dependencies; Type: TABLE; Schema: badgehub; Owner: badgehub -- @@ -329,6 +351,13 @@ ALTER TABLE ONLY badgehub.migrations ALTER COLUMN id SET DEFAULT nextval('badgeh ALTER TABLE ONLY badgehub.project_statuses_on_badges ALTER COLUMN id SET DEFAULT nextval('badgehub.project_statuses_on_badges_id_seq'::regclass); +-- +-- Name: users id; Type: DEFAULT; Schema: badgehub; Owner: badgehub +-- + +ALTER TABLE ONLY badgehub.users ALTER COLUMN id SET DEFAULT nextval('badgehub.users_id_seq'::regclass); + + -- -- Name: versioned_dependencies id; Type: DEFAULT; Schema: badgehub; Owner: badgehub -- @@ -892,6 +921,13 @@ SELECT pg_catalog.setval('badgehub.migrations_id_seq', 1, true); SELECT pg_catalog.setval('badgehub.project_statuses_on_badges_id_seq', 105, true); +-- +-- Name: users_id_seq; Type: SEQUENCE SET; Schema: badgehub; Owner: badgehub +-- + +SELECT pg_catalog.setval('badgehub.users_id_seq', 1, false); + + -- -- Name: versioned_dependencies_id_seq; Type: SEQUENCE SET; Schema: badgehub; Owner: badgehub -- diff --git a/package.json b/package.json index d4c1f53..644e510 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test-db:up": "docker compose -f docker-compose.test-db.yml up -d --wait", "test-db:down": "docker compose -f docker-compose.test-db.yml down", "backup": "docker exec -it badgehub-api-db-1 /usr/bin/pg_dump --username badgehub badgehub -f /var/backup/data-backup-`date +\"%Y-%m-%dT%H:%m\"`.sql", + "overwrite-mockup-data": "docker exec -it badgehub-api-db-1 /usr/bin/pg_dump --username badgehub --schema badgehub badgehub > mockup-data.sql", "test": "vitest --coverage.enabled true", "db-migrate:up": "db-migrate up", "db-migrate:down": "db-migrate down", diff --git a/public/swagger.json b/public/swagger.json index a9f4a8f..41377a2 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -148,7 +148,8 @@ "format": "date-time" }, "id": { - "type": "string" + "type": "number", + "format": "double" }, "email": { "type": "string" @@ -447,7 +448,8 @@ "type": "string" }, "user_id": { - "type": "string" + "type": "number", + "format": "double" }, "git": { "type": "string" @@ -574,6 +576,224 @@ "properties": {}, "type": "object", "additionalProperties": false + }, + "Pick_DBInsertUser.Exclude_keyofDBInsertUser.id__": { + "properties": { + "email": { + "type": "string" + }, + "admin": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "remember_token": { + "type": "string" + }, + "editor": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "show_projects": { + "type": "boolean" + }, + "email_verified_at": { + "type": "string" + } + }, + "required": ["email", "name", "password"], + "type": "object", + "description": "From T, pick a set of properties whose keys are in the union K" + }, + "UserProps": { + "properties": { + "email": { + "type": "string" + }, + "admin": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "remember_token": { + "type": "string" + }, + "editor": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "show_projects": { + "type": "boolean" + }, + "email_verified_at": { + "type": "string" + } + }, + "required": ["email", "name", "password"], + "type": "object", + "additionalProperties": false + }, + "Pick_DBInsertProject.Exclude_keyofDBInsertProject.slug__": { + "properties": { + "version_id": { + "type": "number", + "format": "double" + }, + "git": { + "type": "string" + }, + "allow_team_fixes": { + "type": "boolean" + }, + "user_id": { + "type": "number", + "format": "double" + } + }, + "required": ["user_id"], + "type": "object", + "description": "From T, pick a set of properties whose keys are in the union K" + }, + "ProjectProps": { + "properties": { + "version_id": { + "type": "number", + "format": "double" + }, + "git": { + "type": "string" + }, + "allow_team_fixes": { + "type": "boolean" + }, + "user_id": { + "type": "number", + "format": "double" + } + }, + "required": ["user_id"], + "type": "object", + "additionalProperties": false + }, + "ProjectPropsPartial": { + "properties": { + "version_id": { + "type": "number", + "format": "double" + }, + "git": { + "type": "string" + }, + "allow_team_fixes": { + "type": "boolean" + }, + "user_id": { + "type": "number", + "format": "double" + } + }, + "type": "object", + "additionalProperties": false + }, + "Record_string.string_": { + "properties": {}, + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Construct a type with a set of properties K of type T" + }, + "Record_string._source-string--destination-string_-Array_": { + "properties": {}, + "additionalProperties": { + "items": { + "properties": { + "destination": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": ["destination", "source"], + "type": "object" + }, + "type": "array" + }, + "type": "object", + "description": "Construct a type with a set of properties K of type T" + }, + "DbInsertAppMetadataJSONPartial": { + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "$ref": "#/components/schemas/AppCategoryName" + }, + "author": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "license_file": { + "type": "string" + }, + "is_library": { + "type": "boolean" + }, + "is_hidden": { + "type": "boolean" + }, + "semantic_version": { + "type": "string" + }, + "interpreter": { + "type": "string" + }, + "main_executable": { + "type": "string" + }, + "main_executable_overrides": { + "$ref": "#/components/schemas/Record_string.string_" + }, + "file_mappings": { + "items": { + "properties": { + "destination": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": ["destination", "source"], + "type": "object" + }, + "type": "array" + }, + "file_mappings_overrides": { + "$ref": "#/components/schemas/Record_string._source-string--destination-string_-Array_" + } + }, + "type": "object", + "additionalProperties": false } }, "securitySchemes": {} @@ -739,6 +959,89 @@ } } ] + }, + "post": { + "operationId": "CreateApp", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "Create a new app", + "tags": ["private"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectSlug" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectProps" + } + } + } + } + }, + "delete": { + "operationId": "DeleteApp", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "Create a new app", + "tags": ["private"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectSlug" + } + } + ] + }, + "patch": { + "operationId": "UpdateApp", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "Create a new app", + "tags": ["private"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectSlug" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectPropsPartial" + } + } + } + } } }, "/api/v3/apps/{slug}/files/latest/{filePath}": { @@ -895,26 +1198,235 @@ ] } }, - "/api/v3/apps/{slug}/version": { + "/api/v3/users/{userId}": { "post": { - "operationId": "CreateVersion", + "operationId": "InsertUser", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "Create a new user", + "tags": ["private"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserProps" + } + } + } + } + } + }, + "/api/v3/apps/{slug}/draft/files/{filePath}": { + "post": { + "operationId": "WriteFile", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "Upload a file to the latest draft version of the project.", + "tags": ["private"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "filePath", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/components/schemas/Uint8Array" + } + ] + } + } + } + } + }, + "get": { + "operationId": "GetDraftFile", "responses": { "200": { "description": "Ok", "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/Badge" - }, - "type": "array" + "$ref": "#/components/schemas/Uint8Array" } } } } }, - "description": "Get list of devices (badges)", - "tags": ["public"], + "description": "get the latest draft version of the project.", + "tags": ["private"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "filePath", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/v3/apps/{slug}/draft/metadata": { + "patch": { + "operationId": "ChangeAppMetadata", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "Change the metadata of the latest draft version of the project.", + "tags": ["private"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DbInsertAppMetadataJSONPartial" + } + } + } + } + } + }, + "/api/v3/apps/{slug}/draft/zip": { + "get": { + "operationId": "GetLatestPublishedZip", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Uint8Array" + } + } + } + } + }, + "description": "get the latest draft version of the app in zip format", + "tags": ["private"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "operationId": "WriteZip", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + } + }, + "description": "Upload a file to the latest draft version of the project.", + "tags": ["private"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Uint8Array" + } + } + } + } + } + }, + "/api/v3/apps/{slug}/publish": { + "patch": { + "operationId": "PublishVersion", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "Publish the latest draft version", + "tags": ["private"], "security": [], "parameters": [ { diff --git a/src/controllers/private-rest.ts b/src/controllers/private-rest.ts index 752eb08..3d50bc0 100644 --- a/src/controllers/private-rest.ts +++ b/src/controllers/private-rest.ts @@ -1,14 +1,129 @@ -import { Path, Post, Route, Tags } from "tsoa"; -import { Badge } from "@domain/readModels/Badge"; +import { Body, Delete, Get, Patch, Path, Post, Route, Tags } from "tsoa"; +import type { BadgeHubDataPort } from "@domain/BadgeHubDataPort"; +import { BadgeHubDataPostgresAdapter } from "@db/BadgeHubDataPostgresAdapter"; +import type { Version } from "@domain/readModels/app/Version"; +import type { ProjectSlug } from "@domain/readModels/app/Project"; +import type { DBInsertUser, DBUser } from "@db/models/app/DBUser"; +import type { DBInsertProject } from "@db/models/app/DBProject"; +import type { DBInsertAppMetadataJSON } from "@db/models/app/DBAppMetadataJSON"; +interface UserProps extends Omit {} + +interface ProjectProps extends Omit {} +interface ProjectPropsPartial extends Partial {} +interface DbInsertAppMetadataJSONPartial + extends Partial {} + +// TODO verify user_name against logged in user @Route("/api/v3") -@Tags("public") +@Tags("private") export class PrivateRestController { + public constructor( + private badgeHubData: BadgeHubDataPort = new BadgeHubDataPostgresAdapter() + ) {} + /** - * Get list of devices (badges) + * Create a new user */ - @Post("/apps/{slug}/version") - public async createVersion(@Path() slug: string): Promise { + @Post("/users/{userId}") + public async insertUser( + @Path() userId: DBUser["id"], + @Body() props: UserProps + ): Promise { + // TODO implement with proper password handling (salting, hashing, ...) throw new Error("Not implemented"); } + + /** + * Create a new app + */ + @Post("/apps/{slug}") + public async createApp( + @Path() slug: ProjectSlug, + @Body() props: ProjectProps + ): Promise { + await this.badgeHubData.insertProject({ ...props, slug }); + } + + /** + * Create a new app + */ + @Delete("/apps/{slug}") + public async deleteApp(@Path() slug: ProjectSlug): Promise { + await this.badgeHubData.deleteProject(slug); + } + + /** + * Create a new app + */ + @Patch("/apps/{slug}") + public async updateApp( + @Path() slug: ProjectSlug, + @Body() changes: ProjectPropsPartial + ): Promise { + await this.badgeHubData.updateProject(slug, changes); + } + + /** + * Upload a file to the latest draft version of the project. + */ + @Post("/apps/{slug}/draft/files/{filePath}") + public async writeFile( + @Path() slug: string, + @Path() filePath: string, + @Body() fileContent: string | Uint8Array + ): Promise { + await this.badgeHubData.writeDraftFile(slug, filePath, fileContent); + } + + /** + * Change the metadata of the latest draft version of the project. + */ + @Patch("/apps/{slug}/draft/metadata") + public async changeAppMetadata( + @Path() slug: string, + @Body() appMetadataChanges: DbInsertAppMetadataJSONPartial + ): Promise { + await this.badgeHubData.updateDraftMetadata(slug, appMetadataChanges); + } + + /** + * get the latest draft version of the project. + */ + @Get("/apps/{slug}/draft/files/{filePath}") + public async getDraftFile( + @Path() slug: string, + @Path() filePath: string + ): Promise { + return await this.badgeHubData.getFileContents(slug, "draft", filePath); + } + + /** + * get the latest draft version of the app in zip format + */ + @Get("/apps/{slug}/draft/zip") + public async getLatestPublishedZip( + @Path() slug: string + ): Promise { + return await this.badgeHubData.getVersionZipContents(slug, "draft"); + } + + /** + * Upload a file to the latest draft version of the project. + */ + @Post("/apps/{slug}/draft/zip") + public async writeZip( + @Path() slug: string, + @Body() zipContent: Uint8Array + ): Promise { + return await this.badgeHubData.writeDraftProjectZip(slug, zipContent); + } + + /** + * Publish the latest draft version + */ + @Patch("/apps/{slug}/publish") + public async publishVersion(@Path() slug: string) { + await this.badgeHubData.publishVersion(slug); + } } diff --git a/src/db/BadgeHubDataPostgresAdapter.ts b/src/db/BadgeHubDataPostgresAdapter.ts index 21bf21b..69feb0d 100644 --- a/src/db/BadgeHubDataPostgresAdapter.ts +++ b/src/db/BadgeHubDataPostgresAdapter.ts @@ -1,12 +1,19 @@ import { BadgeHubDataPort } from "@domain/BadgeHubDataPort"; import { Badge } from "@domain/readModels/Badge"; -import { Project } from "@domain/readModels/app/Project"; +import { + Project, + ProjectCore, + ProjectSlug, +} from "@domain/readModels/app/Project"; import { User } from "@domain/readModels/app/User"; import { Version } from "@domain/readModels/app/Version"; import { Category } from "@domain/readModels/app/Category"; import { Pool } from "pg"; import { getPool } from "@db/connectionPool"; -import sql, { join } from "sql-template-tag"; +import { DBProject as DBProject } from "@db/models/app/DBProject"; +import sql, { join, raw } from "sql-template-tag"; +import { DBInsertUser } from "@db/models/app/DBUser"; +import { getEntriesWithDefinedValues } from "@util/objectEntries"; import { DBBadge } from "@db/models/DBBadge"; import { getBaseSelectProjectQuery, @@ -21,8 +28,27 @@ import { timestampTZToDate, } from "@db/sqlHelpers/dbDates"; import { DBVersion } from "@db/models/app/DBVersion"; -import { DBAppMetadataJSON } from "@db/models/app/DBAppMetadataJSON"; +import { + DBAppMetadataJSON, + DBInsertAppMetadataJSON, +} from "@db/models/app/DBAppMetadataJSON"; import { DBCategory } from "@db/models/app/DBCategory"; +import { + assertValidColumKey, + getInsertKeysAndValuesSql, +} from "@db/sqlHelpers/objectToSQL"; + +function getUpdateAssigmentsSql(changes: Object) { + const changeEntries = getEntriesWithDefinedValues(changes); + if (!changeEntries.length) { + return; + } + return join( + changeEntries.map( + ([key, value]) => sql`${raw(assertValidColumKey(key))} = ${value}` + ) + ); +} export class BadgeHubDataPostgresAdapter implements BadgeHubDataPort { private readonly pool: Pool; @@ -31,6 +57,13 @@ export class BadgeHubDataPostgresAdapter implements BadgeHubDataPort { this.pool = getPool(); } + async insertUser(user: DBInsertUser): Promise { + const { keys, values } = getInsertKeysAndValuesSql(user); + const insertQuery = sql`insert into users (${keys}) + values (${values})`; + await this.pool.query(insertQuery); + } + async getCategories(): Promise { const dbCategoryNames: OmitDatedData[] = await this.pool .query(sql`select name, slug from categories`) @@ -38,11 +71,98 @@ export class BadgeHubDataPostgresAdapter implements BadgeHubDataPort { return dbCategoryNames.map((dbCategory) => dbCategory); } + async insertProject(project: DBProject): Promise { + const { keys, values } = getInsertKeysAndValuesSql(project); + const insertAppMetadataSql = sql`insert into app_metadata_jsons (name) + values (${project.slug})`; + + await this.pool.query(sql` + with inserted_app_metadata as (${insertAppMetadataSql} returning id), + inserted_version as ( + insert + into versions (project_slug, app_metadata_json_id) + values (${project.slug}, (select id from inserted_app_metadata)) returning id) + insert + into projects (${keys}, version_id) + values (${values}, (select id from inserted_version))`); + } + + async updateProject( + projectSlug: ProjectSlug, + changes: Partial> + ): Promise { + const setters = getUpdateAssigmentsSql(changes); + if (!setters) { + return; + } + await this.pool.query(sql`update projects + set ${setters} + where slug = ${projectSlug}`); + } + + async deleteProject(projectSlug: ProjectSlug): Promise { + await this.pool.query(sql`update projects + set deleted_at = now() + where slug = ${projectSlug}`); + } + + async writeDraftFile( + projectSlug: ProjectSlug, + filePath: string, + contents: string | Uint8Array + ): Promise { + if (filePath === "metadata.json") { + const appMetadata: DBAppMetadataJSON = JSON.parse( + typeof contents === "string" + ? contents + : new TextDecoder().decode(contents) + ); + const setters = getUpdateAssigmentsSql(appMetadata); + if (!setters) { + return; + } + + const appMetadataUpdateQuery = sql`update app_metadata_jsons + set ${setters} + where id = (select app_metadata_json_id + from versions v + where v.id = + (select projects.version_id from projects where slug = ${projectSlug}))`; + await this.pool.query(appMetadataUpdateQuery); + } else { + throw new Error( + "Method not implemented for files other than the metadata.json file yet." + ); + } + } + + updateDraftMetadata( + slug: string, + appMetadataChanges: Partial + ): Promise { + throw new Error("Method not implemented."); + } + + writeDraftProjectZip( + projectSlug: string, + zipContent: Uint8Array + ): Promise { + throw new Error("Method not implemented."); + } + + async publishVersion(projectSlug: string): Promise { + await this.pool.query( + sql`update versions v + set published_at=now() + where (published_at is null and v.id = (select version_id from projects p where slug = ${projectSlug}))` + ); + } + async getProject(projectSlug: string): Promise { return (await this.getProjects({ projectSlug }))[0]!; } - // TODO test + // TODO use and test async getDraftVersion(projectSlug: string): Promise { const selectVersionIdForProject = sql`select version_id from projects p where p.slug = ${projectSlug}`; const dbVersion: DBVersion & { app_metadata: DBAppMetadataJSON } = @@ -78,7 +198,11 @@ export class BadgeHubDataPostgresAdapter implements BadgeHubDataPort { }; } - getUser(userId: string): Promise { + getUser(userId: User["id"]): Promise { + throw new Error("Method not implemented."); + } + + updateUser(updatedUser: User): Promise { throw new Error("Method not implemented."); } diff --git a/src/db/models/DBProjectStatusOnBadge.ts b/src/db/models/DBProjectStatusOnBadge.ts index 7b47772..6d5446e 100644 --- a/src/db/models/DBProjectStatusOnBadge.ts +++ b/src/db/models/DBProjectStatusOnBadge.ts @@ -3,11 +3,15 @@ import { BadgeSlugRelation } from "./DBBadge"; import { ProjectStatusName } from "@domain/readModels/app/Project"; import { ProjectSlugRelation } from "@db/models/app/DBProject"; +export interface DBInsertProjectStatusOnBadge + extends BadgeSlugRelation, + ProjectSlugRelation { + status?: ProjectStatusName; // Status for this project for this particular badge +} + // table name: project_statuses_on_badges export interface DBProjectStatusOnBadge - extends DBDatedData, - BadgeSlugRelation, - ProjectSlugRelation { + extends DBInsertProjectStatusOnBadge, + DBDatedData { id: number; - status: ProjectStatusName; // Status for this project for this particular badge } diff --git a/src/db/models/README.md b/src/db/models/README.md index 451756b..ba84b84 100644 --- a/src/db/models/README.md +++ b/src/db/models/README.md @@ -5,3 +5,4 @@ In the db models, we try to stick to 2 principles - DRY, no data duplication, unless it's accidental duplication - No arrays. Arrays make querying and switching to another DB more difficult. So instead of arrays, we try to work with relation tables. - There is currently an exception here for the MetaDataFileContents, there the mappings are records and arrays and records with arrays, this is because the data comes from the json file and we could also just store it as stringified JSON. +- Prefer slugs as primary keys where possible, because they are more human-readable which makes the api calls and db queries easier to read. diff --git a/src/db/models/app/DBUser.ts b/src/db/models/app/DBUser.ts index 53c7177..f0bc936 100644 --- a/src/db/models/app/DBUser.ts +++ b/src/db/models/app/DBUser.ts @@ -5,7 +5,7 @@ export interface UserRelation { user_id: DBUser["id"]; } export interface DBInsertUser { - id: string; + id: number; email: string; admin?: boolean; name: string; diff --git a/src/db/sqlHelpers/objectToSQL.ts b/src/db/sqlHelpers/objectToSQL.ts new file mode 100644 index 0000000..fea19e8 --- /dev/null +++ b/src/db/sqlHelpers/objectToSQL.ts @@ -0,0 +1,20 @@ +import { getEntriesWithDefinedValues } from "@util/objectEntries"; +import { join, raw } from "sql-template-tag"; + +const COLUMN_KEY_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +export function assertValidColumKey(key: string): string { + if (!COLUMN_KEY_REGEX.test(key)) { + throw new Error("Invalid column key: " + key); + } + return key; +} + +export function getInsertKeysAndValuesSql(user: Object) { + const definedEntries = getEntriesWithDefinedValues(user); + const keys = join( + definedEntries.map(([key]) => raw(assertValidColumKey(key))) + ); + const values = join(definedEntries.map(([, value]) => value)); + return { keys, values }; +} diff --git a/src/domain/BadgeHubDataPort.ts b/src/domain/BadgeHubDataPort.ts index 5884109..276f12e 100644 --- a/src/domain/BadgeHubDataPort.ts +++ b/src/domain/BadgeHubDataPort.ts @@ -4,14 +4,43 @@ import { User } from "@domain/readModels/app/User"; import { FileMetadata } from "@domain/readModels/app/FileMetadata"; import { Badge } from "@domain/readModels/Badge"; import { Category } from "@domain/readModels/app/Category"; +import { DBInsertUser } from "@db/models/app/DBUser"; +import { DBInsertProject, DBProject } from "@db/models/app/DBProject"; +import { DBInsertAppMetadataJSON } from "@db/models/app/DBAppMetadataJSON"; export interface BadgeHubDataPort { + insertUser(user: DBInsertUser): Promise; + + insertProject(project: DBInsertProject): Promise; + + updateProject( + projectSlug: ProjectSlug, + changes: Partial> + ): Promise; + + deleteProject(projectSlug: ProjectSlug): Promise; + + writeDraftFile( + projectSlug: ProjectSlug, + filePath: string, + contents: string | Uint8Array + ): Promise; + + writeDraftProjectZip( + projectSlug: string, + zipContent: Uint8Array + ): Promise; + + publishVersion(projectSlug: ProjectSlug): Promise; // Publishes the current state of the app as a version + getProject(projectSlug: ProjectSlug): Promise; getDraftVersion(projectSlug: ProjectSlug): Promise; getUser(userId: User["id"]): Promise; + updateUser(updatedUser: User): Promise; + getFileContents( projectSlug: Project["slug"], versionRevision: number | "draft" | "latest", @@ -33,4 +62,9 @@ export interface BadgeHubDataPort { badgeSlug?: Badge["slug"]; categorySlug?: Category["slug"]; }): Promise; + + updateDraftMetadata( + slug: string, + appMetadataChanges: Partial + ): Promise; } diff --git a/src/domain/readModels/app/User.ts b/src/domain/readModels/app/User.ts index fb8b0f3..0e6d094 100644 --- a/src/domain/readModels/app/User.ts +++ b/src/domain/readModels/app/User.ts @@ -5,7 +5,7 @@ export interface UserRelation { } export interface User extends DatedData { - id: string; + id: number; email: string; admin: boolean; name: string; diff --git a/src/generated/routes.ts b/src/generated/routes.ts index 9097946..a3d701d 100644 --- a/src/generated/routes.ts +++ b/src/generated/routes.ts @@ -107,7 +107,7 @@ const models: TsoaRoute.Models = { created_at: { dataType: "datetime", required: true }, updated_at: { dataType: "datetime", required: true }, deleted_at: { dataType: "datetime" }, - id: { dataType: "string", required: true }, + id: { dataType: "double", required: true }, email: { dataType: "string", required: true }, admin: { dataType: "boolean", required: true }, name: { dataType: "string", required: true }, @@ -266,7 +266,7 @@ const models: TsoaRoute.Models = { dataType: "refObject", properties: { slug: { dataType: "string", required: true }, - user_id: { dataType: "string", required: true }, + user_id: { dataType: "double", required: true }, git: { dataType: "string" }, allow_team_fixes: { dataType: "boolean" }, created_at: { dataType: "datetime", required: true }, @@ -323,6 +323,138 @@ const models: TsoaRoute.Models = { additionalProperties: false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "Pick_DBInsertUser.Exclude_keyofDBInsertUser.id__": { + dataType: "refAlias", + type: { + dataType: "nestedObjectLiteral", + nestedProperties: { + email: { dataType: "string", required: true }, + admin: { dataType: "boolean" }, + name: { dataType: "string", required: true }, + password: { dataType: "string", required: true }, + remember_token: { dataType: "string" }, + editor: { dataType: "string" }, + public: { dataType: "boolean" }, + show_projects: { dataType: "boolean" }, + email_verified_at: { dataType: "string" }, + }, + validators: {}, + }, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + UserProps: { + dataType: "refObject", + properties: { + email: { dataType: "string", required: true }, + admin: { dataType: "boolean" }, + name: { dataType: "string", required: true }, + password: { dataType: "string", required: true }, + remember_token: { dataType: "string" }, + editor: { dataType: "string" }, + public: { dataType: "boolean" }, + show_projects: { dataType: "boolean" }, + email_verified_at: { dataType: "string" }, + }, + additionalProperties: false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "Pick_DBInsertProject.Exclude_keyofDBInsertProject.slug__": { + dataType: "refAlias", + type: { + dataType: "nestedObjectLiteral", + nestedProperties: { + version_id: { dataType: "double" }, + git: { dataType: "string" }, + allow_team_fixes: { dataType: "boolean" }, + user_id: { dataType: "double", required: true }, + }, + validators: {}, + }, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + ProjectProps: { + dataType: "refObject", + properties: { + version_id: { dataType: "double" }, + git: { dataType: "string" }, + allow_team_fixes: { dataType: "boolean" }, + user_id: { dataType: "double", required: true }, + }, + additionalProperties: false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + ProjectPropsPartial: { + dataType: "refObject", + properties: { + version_id: { dataType: "double" }, + git: { dataType: "string" }, + allow_team_fixes: { dataType: "boolean" }, + user_id: { dataType: "double" }, + }, + additionalProperties: false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "Record_string.string_": { + dataType: "refAlias", + type: { + dataType: "nestedObjectLiteral", + nestedProperties: {}, + additionalProperties: { dataType: "string" }, + validators: {}, + }, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "Record_string._source-string--destination-string_-Array_": { + dataType: "refAlias", + type: { + dataType: "nestedObjectLiteral", + nestedProperties: {}, + additionalProperties: { + dataType: "array", + array: { + dataType: "nestedObjectLiteral", + nestedProperties: { + destination: { dataType: "string", required: true }, + source: { dataType: "string", required: true }, + }, + }, + }, + validators: {}, + }, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + DbInsertAppMetadataJSONPartial: { + dataType: "refObject", + properties: { + name: { dataType: "string" }, + description: { dataType: "string" }, + category: { ref: "AppCategoryName" }, + author: { dataType: "string" }, + icon: { dataType: "string" }, + license_file: { dataType: "string" }, + is_library: { dataType: "boolean" }, + is_hidden: { dataType: "boolean" }, + semantic_version: { dataType: "string" }, + interpreter: { dataType: "string" }, + main_executable: { dataType: "string" }, + main_executable_overrides: { ref: "Record_string.string_" }, + file_mappings: { + dataType: "array", + array: { + dataType: "nestedObjectLiteral", + nestedProperties: { + destination: { dataType: "string", required: true }, + source: { dataType: "string", required: true }, + }, + }, + }, + file_mappings_overrides: { + ref: "Record_string._source-string--destination-string_-Array_", + }, + }, + additionalProperties: false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa }; const templateService = new ExpressTemplateService(models, { noImplicitAdditionalProperties: "throw-on-extras", @@ -700,13 +832,440 @@ export function RegisterRoutes(app: Router) { ); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.post( - "/api/v3/apps/:slug/version", + "/api/v3/users/:userId", + ...fetchMiddlewares(PrivateRestController), + ...fetchMiddlewares( + PrivateRestController.prototype.insertUser + ), + + async function PrivateRestController_insertUser( + request: ExRequest, + response: ExResponse, + next: any + ) { + const args: Record = { + userId: { + in: "path", + name: "userId", + required: true, + dataType: "double", + }, + props: { in: "body", name: "props", required: true, ref: "UserProps" }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ + args, + request, + response, + }); + + const controller = new PrivateRestController(); + + await templateService.apiHandler({ + methodName: "insertUser", + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + } + ); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post( + "/api/v3/apps/:slug", + ...fetchMiddlewares(PrivateRestController), + ...fetchMiddlewares( + PrivateRestController.prototype.createApp + ), + + async function PrivateRestController_createApp( + request: ExRequest, + response: ExResponse, + next: any + ) { + const args: Record = { + slug: { in: "path", name: "slug", required: true, ref: "ProjectSlug" }, + props: { + in: "body", + name: "props", + required: true, + ref: "ProjectProps", + }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ + args, + request, + response, + }); + + const controller = new PrivateRestController(); + + await templateService.apiHandler({ + methodName: "createApp", + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + } + ); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.delete( + "/api/v3/apps/:slug", + ...fetchMiddlewares(PrivateRestController), + ...fetchMiddlewares( + PrivateRestController.prototype.deleteApp + ), + + async function PrivateRestController_deleteApp( + request: ExRequest, + response: ExResponse, + next: any + ) { + const args: Record = { + slug: { in: "path", name: "slug", required: true, ref: "ProjectSlug" }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ + args, + request, + response, + }); + + const controller = new PrivateRestController(); + + await templateService.apiHandler({ + methodName: "deleteApp", + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + } + ); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.patch( + "/api/v3/apps/:slug", + ...fetchMiddlewares(PrivateRestController), + ...fetchMiddlewares( + PrivateRestController.prototype.updateApp + ), + + async function PrivateRestController_updateApp( + request: ExRequest, + response: ExResponse, + next: any + ) { + const args: Record = { + slug: { in: "path", name: "slug", required: true, ref: "ProjectSlug" }, + changes: { + in: "body", + name: "changes", + required: true, + ref: "ProjectPropsPartial", + }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ + args, + request, + response, + }); + + const controller = new PrivateRestController(); + + await templateService.apiHandler({ + methodName: "updateApp", + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + } + ); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post( + "/api/v3/apps/:slug/draft/files/:filePath", + ...fetchMiddlewares(PrivateRestController), + ...fetchMiddlewares( + PrivateRestController.prototype.writeFile + ), + + async function PrivateRestController_writeFile( + request: ExRequest, + response: ExResponse, + next: any + ) { + const args: Record = { + slug: { in: "path", name: "slug", required: true, dataType: "string" }, + filePath: { + in: "path", + name: "filePath", + required: true, + dataType: "string", + }, + fileContent: { + in: "body", + name: "fileContent", + required: true, + dataType: "union", + subSchemas: [{ dataType: "string" }, { ref: "Uint8Array" }], + }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ + args, + request, + response, + }); + + const controller = new PrivateRestController(); + + await templateService.apiHandler({ + methodName: "writeFile", + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + } + ); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.patch( + "/api/v3/apps/:slug/draft/metadata", + ...fetchMiddlewares(PrivateRestController), + ...fetchMiddlewares( + PrivateRestController.prototype.changeAppMetadata + ), + + async function PrivateRestController_changeAppMetadata( + request: ExRequest, + response: ExResponse, + next: any + ) { + const args: Record = { + slug: { in: "path", name: "slug", required: true, dataType: "string" }, + appMetadataChanges: { + in: "body", + name: "appMetadataChanges", + required: true, + ref: "DbInsertAppMetadataJSONPartial", + }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ + args, + request, + response, + }); + + const controller = new PrivateRestController(); + + await templateService.apiHandler({ + methodName: "changeAppMetadata", + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + } + ); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get( + "/api/v3/apps/:slug/draft/files/:filePath", + ...fetchMiddlewares(PrivateRestController), + ...fetchMiddlewares( + PrivateRestController.prototype.getDraftFile + ), + + async function PrivateRestController_getDraftFile( + request: ExRequest, + response: ExResponse, + next: any + ) { + const args: Record = { + slug: { in: "path", name: "slug", required: true, dataType: "string" }, + filePath: { + in: "path", + name: "filePath", + required: true, + dataType: "string", + }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ + args, + request, + response, + }); + + const controller = new PrivateRestController(); + + await templateService.apiHandler({ + methodName: "getDraftFile", + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + } + ); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get( + "/api/v3/apps/:slug/draft/zip", + ...fetchMiddlewares(PrivateRestController), + ...fetchMiddlewares( + PrivateRestController.prototype.getLatestPublishedZip + ), + + async function PrivateRestController_getLatestPublishedZip( + request: ExRequest, + response: ExResponse, + next: any + ) { + const args: Record = { + slug: { in: "path", name: "slug", required: true, dataType: "string" }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ + args, + request, + response, + }); + + const controller = new PrivateRestController(); + + await templateService.apiHandler({ + methodName: "getLatestPublishedZip", + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + } + ); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post( + "/api/v3/apps/:slug/draft/zip", + ...fetchMiddlewares(PrivateRestController), + ...fetchMiddlewares( + PrivateRestController.prototype.writeZip + ), + + async function PrivateRestController_writeZip( + request: ExRequest, + response: ExResponse, + next: any + ) { + const args: Record = { + slug: { in: "path", name: "slug", required: true, dataType: "string" }, + zipContent: { + in: "body", + name: "zipContent", + required: true, + ref: "Uint8Array", + }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ + args, + request, + response, + }); + + const controller = new PrivateRestController(); + + await templateService.apiHandler({ + methodName: "writeZip", + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + } + ); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.patch( + "/api/v3/apps/:slug/publish", ...fetchMiddlewares(PrivateRestController), ...fetchMiddlewares( - PrivateRestController.prototype.createVersion + PrivateRestController.prototype.publishVersion ), - async function PrivateRestController_createVersion( + async function PrivateRestController_publishVersion( request: ExRequest, response: ExResponse, next: any @@ -728,7 +1287,7 @@ export function RegisterRoutes(app: Router) { const controller = new PrivateRestController(); await templateService.apiHandler({ - methodName: "createVersion", + methodName: "publishVersion", controller, response, next, diff --git a/src/index.ts b/src/index.ts index f85bca6..7e2852f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,18 @@ import app from "./app"; import { RegisterRoutes } from "./generated/routes"; import { addTsoaValidationFailureLogging } from "@util/logging"; -import { EXPRESS_PORT } from "@config"; +import { EXPRESS_PORT, NODE_ENV } from "@config"; import { disableWriteWhenNotDev } from "@disableWriteWhenNotDev"; import { runMigrations } from "@db/migrations"; +import setupPopulateDBApi from "./setupPopulateDBApi"; async function startServer() { disableWriteWhenNotDev(app); + if (NODE_ENV === "development") { + setupPopulateDBApi(app); + } + RegisterRoutes(app); addTsoaValidationFailureLogging(app); diff --git a/src/populateDB.ts b/src/setupPopulateDBApi.ts similarity index 60% rename from src/populateDB.ts rename to src/setupPopulateDBApi.ts index 1578f39..585040f 100644 --- a/src/populateDB.ts +++ b/src/setupPopulateDBApi.ts @@ -7,10 +7,34 @@ import { POSTGRES_PORT, POSTGRES_USER, } from "@config"; +import sql from "sql-template-tag"; +import { DBInsertUser } from "@db/models/app/DBUser"; +import { DBDatedData } from "@db/models/app/DBDatedData"; +import { DBInsertProject } from "@db/models/app/DBProject"; +import { BadgeHubDataPostgresAdapter } from "@db/BadgeHubDataPostgresAdapter"; +import { DBInsertAppMetadataJSON } from "@db/models/app/DBAppMetadataJSON"; +import { getInsertKeysAndValuesSql } from "@db/sqlHelpers/objectToSQL"; +import { DBInsertProjectStatusOnBadge } from "@db/models/DBProjectStatusOnBadge"; -const CATEGORIES_COUNT = 15; - -export default async function populateDB(app: Express) { +const CATEGORY_NAMES = [ + "Uncategorised", + "Event related", + "Games", + "Graphics", + "Hardware", + "Utility", + "Wearable", + "Data", + "Silly", + "Hacking", + "Troll", + "Unusable", + "Adult", + "Virus", + // "Interpreter", // TODO add Interpreter to mock data? +] as const; +const CATEGORIES_COUNT = CATEGORY_NAMES.length; +export default async function setupPopulateDBApi(app: Express) { const router = Router(); app.use(express.json()); @@ -25,27 +49,29 @@ export default async function populateDB(app: Express) { port: POSTGRES_PORT, }); - router.get("/populate", async (req, res) => { - await deleteDatabases(pool); + router.post("/populate", async (req, res) => { + await cleanDatabases(pool); await populateDatabases(pool); return res.status(200).send("Population done."); }); } -async function deleteDatabases(pool: pg.Pool) { +async function cleanDatabases(pool: pg.Pool) { const client = await pool.connect(); - await client.query("DELETE FROM badgehub.badge_project"); - await client.query("DELETE FROM badgehub.dependencies"); - await client.query("DELETE FROM badgehub.users"); - await client.query("DELETE FROM badgehub.projects"); + await client.query(sql`delete from badgehub.users`); + await client.query(sql`delete from badgehub.projects`); + await client.query(sql`delete from badgehub.app_metadata_jsons`); + await client.query(sql`delete from badgehub.versions`); + await client.query(sql`delete from badgehub.versioned_dependencies`); + await client.query(sql`delete from badgehub.project_statuses_on_badges`); client.release(); } async function populateDatabases(pool: pg.Pool) { const client = await pool.connect(); const userCount = await insertUsers(client); - const projectCount = await insertProjects(client, userCount); - await badgeProjectCrossTable(client, projectCount); + const projectSlugs = await insertProjects(client, userCount); + await badgeProjectCrossTable(client, projectSlugs); client.release(); } @@ -156,7 +182,7 @@ async function insertUsers(client: pg.PoolClient) { "hotmail.com", ]; - for (const id in users) { + for (let id = 0; id < users.length; id++) { const isAdmin = random(10) == 0; const name = users[id]!; const email = `${name.toLowerCase()}@${domains[random(domains.length)]}`; @@ -168,30 +194,26 @@ async function insertUsers(client: pg.PoolClient) { const updatedAt = date(createDate + random(100)); console.log(`insert into users ${name}`); - - await client.query( - `INSERT INTO badgehub.users - (id, admin, name, email, password, public, show_projects, created_at, updated_at) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - [ - id, - isAdmin, - name, - email, - password, - isPublic, - showProjects, - createdAt, - updatedAt, - ] - ); + const toInsert: DBInsertUser & DBDatedData = { + id, + admin: isAdmin, + name, + email, + password, + public: isPublic, + show_projects: showProjects, + created_at: createdAt, + updated_at: updatedAt, + }; + const badgeHubAdapter = new BadgeHubDataPostgresAdapter(); + await badgeHubAdapter.insertUser(toInsert); } return users.length; } async function insertProjects(client: pg.PoolClient, userCount: number) { - const apps = [ + const projectSlugs = [ "CodeCraft", "PixelPulse", "BitBlast", @@ -280,74 +302,73 @@ async function insertProjects(client: pg.PoolClient, userCount: number) { "HackQuest", "SecureSphere", ]; + const badgeHubAdapter = new BadgeHubDataPostgresAdapter(); - for (const id in apps) { - const name = apps[id]!; + for (let id = 0; id < projectSlugs.length; id++) { + const name = projectSlugs[id]!; const slug = name.toLowerCase(); const description = getDescription(name); const userId = random(userCount); - const categoryId = random(CATEGORIES_COUNT) + 1; + const categoryId = random(CATEGORIES_COUNT); const createDate = -random(600); const createdAt = date(createDate); const updatedAt = date(createDate + random(100)); console.log(`insert into projects ${name} (${description})`); - await client.query( - `INSERT INTO badgehub.projects - (id, name, slug, description, user_id, category_id, created_at, updated_at) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8)`, - [id, name, slug, description, userId, categoryId, createdAt, updatedAt] + const inserted: DBInsertProject & DBDatedData = { + slug, + user_id: userId, + created_at: createdAt, + updated_at: updatedAt, + }; + + await badgeHubAdapter.insertProject(inserted); + const appMetadata: DBInsertAppMetadataJSON & DBDatedData = { + name, + description, + category: CATEGORY_NAMES[categoryId], + created_at: createdAt, + updated_at: updatedAt, + }; + + await badgeHubAdapter.writeDraftFile( + inserted.slug, + "metadata.json", + JSON.stringify(appMetadata) ); } - return apps.length; + return projectSlugs.map((slug) => slug.toLowerCase()); } async function badgeProjectCrossTable( client: pg.PoolClient, - projectCount: number + projectSlugs: string[] ) { - const badgeIds = [1, 2, 5]; // Hardcoded! Update by hand - for (let index = 0; index < projectCount; index++) { - const badgeId = badgeIds[random(3)]; + const badgeSlugs = ["mch2022", "troopers23", "why2025"] as const; // Hardcoded! Update by hand + for (let index = 0; index < projectSlugs.length; index++) { + const badgeSlug = badgeSlugs[random(3)]!; + let insertObject1: DBInsertProjectStatusOnBadge = { + badge_slug: badgeSlug, + project_slug: projectSlugs[index]!, + }; + const insert1 = getInsertKeysAndValuesSql(insertObject1); await client.query( - `INSERT INTO badgehub.badge_project - (badge_id, project_id) VALUES - ($1, $2)`, - [badgeId, index] + sql`insert into badgehub.project_statuses_on_badges (${insert1.keys}) values (${insert1.values})` ); // Some project support two badges - const badgeId2 = badgeIds[random(3)]; - if (badgeId2 != badgeId && random(3) == 1) { + const badgeId2 = badgeSlugs[random(3)]!; + if (badgeId2 != badgeSlug && random(3) == 1) { + const insertObject2: DBInsertProjectStatusOnBadge = { + badge_slug: badgeId2, + project_slug: projectSlugs[index]!, + }; + const insert2 = getInsertKeysAndValuesSql(insertObject2); await client.query( - `INSERT INTO badgehub.badge_project - (badge_id, project_id) VALUES - ($1, $2)`, - [badgeId2, index] + sql`insert into badgehub.project_statuses_on_badges (${insert2.keys}) values (${insert2.values})` ); } } } - -// Not in use right now -async function userProjectsCrossTable( - client: pg.PoolClient, - userCount: number, - projectCount: number -) { - for (let index = 0; index < 300; index++) { - const userId = random(userCount); - const projectId = random(projectCount); - const createDate = -random(600); - const createdAt = date(createDate); - const updatedAt = date(createDate + random(100)); - await client.query( - `INSERT INTO badgehub.project_user - (id, user_id, project_id, created_at, updated_at) VALUES - ($1, $2, $3, $4, $5)`, - [index, userId, projectId, createdAt, updatedAt] - ); - } -} diff --git a/src/util/objectEntries.ts b/src/util/objectEntries.ts new file mode 100644 index 0000000..4d2e477 --- /dev/null +++ b/src/util/objectEntries.ts @@ -0,0 +1,18 @@ +function entryHasDefinedValue( + entry: [K, V] +): entry is [K, V extends undefined ? never : V] { + return entry[1] !== undefined; +} + +export type Entry = NonNullable< + { + [K in keyof T]: [K, T[K]]; + }[keyof T] +>; + +export function getEntriesWithDefinedValues( + obj: T +): Entry[] { + const typedEntries = Object.entries(obj) as Entry[]; + return typedEntries.filter(entryHasDefinedValue); +}