From f900ecb3dde3f3da09ac01fbb64ec67189c1c759 Mon Sep 17 00:00:00 2001 From: Rob Colbert Date: Tue, 28 Apr 2026 16:18:21 -0400 Subject: [PATCH] added project service --- gadget-code/package.json | 2 + gadget-code/src/models/project.ts | 3 +- gadget-code/src/services/project.ts | 153 +++++++++++++++++++++++++ packages/api/src/interfaces/project.ts | 7 ++ pnpm-lock.yaml | 17 +++ 5 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 gadget-code/src/services/project.ts diff --git a/gadget-code/package.json b/gadget-code/package.json index 872c005..0efa43e 100644 --- a/gadget-code/package.json +++ b/gadget-code/package.json @@ -55,6 +55,7 @@ "react-router-dom": "^7.14.2", "rotating-file-stream": "^3.2.6", "serve-favicon": "^2.5.1", + "slug": "^11.0.1", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "uikit": "^3.23.11", @@ -81,6 +82,7 @@ "@types/numeral": "^2.0.5", "@types/react": "^19.2.14", "@types/serve-favicon": "^2.5.7", + "@types/slug": "^5.0.9", "@types/uikit": "^3.14.5", "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^6.0.1", diff --git a/gadget-code/src/models/project.ts b/gadget-code/src/models/project.ts index 8a77988..3ec5f7f 100644 --- a/gadget-code/src/models/project.ts +++ b/gadget-code/src/models/project.ts @@ -4,11 +4,12 @@ import { Types, Schema, model } from "mongoose"; -import { IProject } from "@gadget/api"; +import { ProjectStatus, IProject } from "@gadget/api"; export const ProjectSchema = new Schema({ createdAt: { type: Date, default: Date.now, required: true }, user: { type: Types.ObjectId, required: true, index: 1, ref: "User" }, + status: { type: String, enum: ProjectStatus, required: true }, name: { type: String, default: "New Project", required: true }, slug: { type: String, diff --git a/gadget-code/src/services/project.ts b/gadget-code/src/services/project.ts new file mode 100644 index 0000000..86a7bbe --- /dev/null +++ b/gadget-code/src/services/project.ts @@ -0,0 +1,153 @@ +// services/project.ts +// Copyright (C) 2026 Robert Colbert +// All Rights Reserved + +import slug from "slug"; + +import { MongooseBaseQueryOptions, PopulateOptions } from "mongoose"; + +import { IProject, IUser, ProjectStatus } from "@gadget/api"; +import Project from "@/models/project.js"; + +import { DtpService } from "../lib/service.js"; + +export interface IProjectDefinition { + name: string; + slug: string; + gitUrl?: string; + status?: ProjectStatus; +} + +class ProjectService extends DtpService { + private populateProject: PopulateOptions[]; + + get name(): string { + return "ProjectService"; + } + + get slug(): string { + return "svc:project"; + } + + constructor() { + super(); + this.populateProject = [ + { + path: "user", + select: "-passwordSalt -password", + }, + ]; + } + + async start(): Promise { + this.log.info("service started"); + } + + async stop(): Promise { + this.log.info("service stopped"); + } + + async create(user: IUser, definition: IProjectDefinition): Promise { + const NOW = new Date(); + + const project = new Project(); + project.createdAt = NOW; + project.user = user._id; + project.status = ProjectStatus.Active; + project.name = definition.name; + project.slug = slug(definition.slug.trim().toLowerCase()); + + if (definition.gitUrl) { + project.gitUrl = definition.gitUrl; + } + + this.log.info("creating project", { name: project.name }); + await project.save(); + + return project.populate(this.populateProject); + } + + async update( + project: IProject, + definition: IProjectDefinition, + ): Promise { + const update: MongooseBaseQueryOptions = { $set: {}, $unset: {} }; + + if (definition.status) { + if (definition.status !== project.status) { + update.$set.status = definition.status; + } + } + + if (definition.name) { + if (definition.name !== project.name) { + update.$set.name = definition.name; + } + } + + if (definition.slug) { + if (definition.slug !== project.slug) { + update.$set.slug = slug(definition.slug.trim().toLowerCase()); + } + } + + if (definition.gitUrl) { + if (definition.gitUrl !== project.gitUrl) { + update.$set.gitUrl = definition.gitUrl; + } + } else { + update.$unset.gitUrl = 1; + } + + const newProject = await Project.findOneAndUpdate( + { _id: project._id }, + update, + { new: true, populate: this.populateProject }, + ); + if (!newProject) { + const error = new Error("project has been removed"); + error.statusCode = 404; + throw error; + } + + this.log.info("project updated", { + old: project.toObject ? project.toObject() : project, + new: newProject.toObject(), + }); + + return newProject; + } + + async setStatus(project: IProject, status: ProjectStatus): Promise { + const newProject = await Project.findOneAndUpdate( + { _id: project._id }, + { $set: { status } }, + { new: true, populate: this.populateProject }, + ); + if (!newProject) { + const error = new Error("project has been removed"); + error.statusCode = 404; + throw error; + } + + this.log.info("project status updated", { + project: { + _id: project._id.toHexString(), + slug: project.slug, + }, + old: project.status, + new: newProject.status, + }); + + return newProject; + } + + async getForUser(user: IUser): Promise { + const projects = await Project.find({ user: user._id }) + .sort({ name: 1 }) + .populate(this.populateProject); + return projects; + } +} + +export default new ProjectService(); diff --git a/packages/api/src/interfaces/project.ts b/packages/api/src/interfaces/project.ts index e35b8b8..6e583c3 100644 --- a/packages/api/src/interfaces/project.ts +++ b/packages/api/src/interfaces/project.ts @@ -5,9 +5,16 @@ import { Document, Types } from "mongoose"; import type { IUser } from "./user.js"; +export enum ProjectStatus { + Active = "active", + Inactive = "inactive", + Archived = "archived", +} + export interface IProject extends Document { createdAt: Date; user: IUser | Types.ObjectId; + status: ProjectStatus; name: string; slug: string; gitUrl?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0caa286..5cc45e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,9 @@ importers: serve-favicon: specifier: ^2.5.1 version: 2.5.1 + slug: + specifier: ^11.0.1 + version: 11.0.1 socket.io: specifier: ^4.8.3 version: 4.8.3 @@ -185,6 +188,9 @@ importers: '@types/serve-favicon': specifier: ^2.5.7 version: 2.5.7 + '@types/slug': + specifier: ^5.0.9 + version: 5.0.9 '@types/uikit': specifier: ^3.14.5 version: 3.23.0 @@ -1159,6 +1165,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/slug@5.0.9': + resolution: {integrity: sha512-6Yp8BSplP35Esa/wOG1wLNKiqXevpQTEF/RcL/NV6BBQaMmZh4YlDwCgrrFSoUE4xAGvnKd5c+lkQJmPrBAzfQ==} + '@types/uikit@3.23.0': resolution: {integrity: sha512-GTn8/K+f4AjFxtLqRKWzjaVKckQp/rHcl20IO09salh2VjyFb9CqeFugL95skO6qbbJLil3PE1MmNajrSj5gMg==} @@ -2792,6 +2801,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slug@11.0.1: + resolution: {integrity: sha512-VrM060OM/E7rdLQSnp6JHrzFfJFmqQBp0+TMhZStnEB8PfNliaZ9UWYjTHGHLUFVJorZ8TjVd/aKvIxHWU2O7g==} + hasBin: true + socket.io-adapter@2.5.6: resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} @@ -3829,6 +3842,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 25.6.0 + '@types/slug@5.0.9': {} + '@types/uikit@3.23.0': {} '@types/uuid@10.0.0': {} @@ -5615,6 +5630,8 @@ snapshots: slash@3.0.0: {} + slug@11.0.1: {} + socket.io-adapter@2.5.6: dependencies: debug: 4.4.3