├── src ├── shared │ ├── types.ts │ └── constants.ts ├── server │ ├── views │ │ ├── includes │ │ │ └── flash.pug │ │ ├── sign_in.pug │ │ ├── sign_up.pug │ │ ├── layout.pug │ │ └── index.pug │ ├── tailwind.css │ ├── context.ts │ ├── middleware │ │ └── requiresAuth.ts │ ├── utils.ts │ ├── tsconfig.json │ ├── errors.ts │ ├── models │ │ ├── session.ts │ │ ├── job.ts │ │ ├── html.ts │ │ ├── registry.ts │ │ ├── package.ts │ │ ├── user.ts │ │ └── organization.ts │ ├── services │ │ ├── mailer.ts │ │ └── jobs.ts │ ├── controllers │ │ ├── html.ts │ │ ├── slack.ts │ │ ├── auth.ts │ │ └── trpc.ts │ ├── session.ts │ └── express.ts ├── index.d.ts ├── db.ts ├── frontend │ ├── style.css │ ├── vite.config.ts │ ├── trpc.ts │ ├── tsconfig.json │ ├── main.ts │ ├── composables │ │ ├── loading.ts │ │ └── modal.ts │ ├── index.html │ ├── components │ │ ├── Card.vue │ │ ├── Button.vue │ │ ├── SideMenu.cy.jsx │ │ ├── SideMenu.cy.tsx │ │ ├── Loader.vue │ │ ├── Input.vue │ │ ├── SideMenu.vue │ │ ├── PkgInfo.vue │ │ └── TrashIcon.vue │ ├── router.ts │ ├── views │ │ ├── DependenciesPage │ │ │ ├── DependencyForm.cy.jsx │ │ │ ├── DependencyForm.cy.tsx │ │ │ └── DependencyForm.vue │ │ ├── NotificationsPage │ │ │ ├── EmailForm.vue │ │ │ └── SlackCard.vue │ │ ├── AccountPage.vue │ │ ├── DependenciesPage.vue │ │ └── NotificationsPage.vue │ └── App.vue ├── context │ ├── context.ts │ └── actions │ │ └── organizations.ts ├── fetchVersions.ts ├── types.ts └── notify.ts ├── .prettierignore ├── .npmrc ├── cypress ├── tasks │ ├── getpid.sh │ ├── resetdb.ts │ └── server.ts ├── fixtures │ └── example.json ├── support │ ├── component-index.html │ ├── e2e.ts │ ├── component.ts │ └── commands.ts └── e2e │ └── authentication.cy.ts ├── postcss.config.cjs ├── .gitignore ├── docs ├── USEFUL_LINKS.md ├── DB.mmd ├── STORIES.md ├── JOBS.md └── OVERVIEW.md ├── tailwind.config.cjs ├── tailwind.server.config.cjs ├── knexfile.js ├── tsconfig.json ├── test ├── fixtures │ ├── organization.ts │ └── job.ts ├── migrations │ ├── 20221220105733_addOrganizationTable.spec.ts │ ├── 20230103105914_addTimezoneToJob.spec.ts │ ├── 20221228123809_addSessionTable.spec.ts │ ├── 20230103022425_addEmailsTable.spec.ts │ ├── 20230105104854_addSlackColumnsToOrganizationTable.spec.ts │ ├── 20221223095059_addModuleTables.spec.ts │ ├── 20221224052443_addJobTable.spec.ts │ └── utils.ts ├── server │ ├── models │ │ ├── package.spec.ts │ │ └── organization.spec.ts │ └── services │ │ └── jobs.spec.ts └── notify.spec.ts ├── scripts ├── dbToTs.ts └── utils.ts ├── migrations ├── 20230103105914_addTimezoneToJob.js ├── 20230103022425_addEmailsTable.js ├── 20230105104854_addSlackColumnsToOrganizationTable.js ├── 20221220105733_addOrganizationTable.js ├── 20221228123809_addSessionTable.js ├── 20221223095059_addModuleTables.js └── 20221224052443_addJobTable.js ├── cypress.config.ts ├── gulpfile.js ├── README.md ├── package.json └── dbschema.ts /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | scripts-prepend-node-path=true 2 | -------------------------------------------------------------------------------- /cypress/tasks/getpid.sh: -------------------------------------------------------------------------------- 1 | lsof -i :4444 | grep node | awk '{print $2}' -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dbschema.js 3 | screenshots 4 | videos 5 | db_erd.svg 6 | src/**/*.js 7 | dist 8 | src/server/style.css 9 | -------------------------------------------------------------------------------- /src/server/views/includes/flash.pug: -------------------------------------------------------------------------------- 1 | div(class='flex items-center justify-center text-red-600') 2 | div(class=flash.type role="alert")= flash.message -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | import { notify_when } from "../../dbschema"; 2 | 3 | export const PORT = 4444; 4 | 5 | export const notifyWhen: notify_when[] = ["major", "minor", "prerelease"]; 6 | -------------------------------------------------------------------------------- /docs/USEFUL_LINKS.md: -------------------------------------------------------------------------------- 1 | # Useful Links 2 | 3 | ## trpc + Vue 4 | 5 | - https://dev.to/alousilva/vue3-typescript-express-trpc-setup-example-2mlh 6 | - https://www.youtube.com/watch?v=otbnC2zE2rw 7 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | // TODO: Figure out the typing for this 3 | import type { DefineComponent } from "vue"; 4 | const component: DefineComponent; 5 | export default component; 6 | } 7 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /tailwind.server.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.pug", "./src/**/*.components.css"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import knex from "knex"; 2 | import knexConfig from "../knexfile.js"; 3 | 4 | export function createKnex(database: string) { 5 | return knex({ 6 | connection: { ...knexConfig.connection, database }, 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /src/frontend/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | @apply bg-gray-100; 7 | } 8 | 9 | h1 { 10 | @apply text-3xl; 11 | } 12 | 13 | h2 { 14 | @apply text-xl; 15 | } 16 | -------------------------------------------------------------------------------- /src/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import vueJsx from "@vitejs/plugin-vue-jsx"; 4 | 5 | export default defineConfig({ 6 | plugins: [vue(), vueJsx()], 7 | }); 8 | -------------------------------------------------------------------------------- /src/server/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | a { 6 | @apply border-b-fuchsia-500 border-b-2; 7 | } 8 | 9 | input { 10 | @apply border rounded w-full border-black p-1 md:my-2; 11 | } 12 | -------------------------------------------------------------------------------- /src/server/context.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | import { knexClient } from "./express.js"; 3 | 4 | export function contextMiddleware( 5 | req: Request, 6 | _res: Response, 7 | next: NextFunction 8 | ) { 9 | req.db = knexClient; 10 | next(); 11 | } 12 | -------------------------------------------------------------------------------- /src/server/middleware/requiresAuth.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | 3 | export function requiresAuth(req: Request, res: Response, next: NextFunction) { 4 | if (req.session.organizationId) { 5 | next(); 6 | } else { 7 | res.redirect("/"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | export default { 2 | client: "pg", 3 | connection: { 4 | user: process.env.POSTGRES_USER || "postgres", 5 | database: process.env.POSTGRES_DB || "notifier_test", 6 | password: process.env.POSTGRES_PASSWORD, 7 | host: process.env.POSTGRES_HOST, 8 | port: 5432, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/server/utils.ts: -------------------------------------------------------------------------------- 1 | export function toHuman(ms: number) { 2 | let s = ms / 1000; 3 | const h = Math.floor(s / 60 / 60); 4 | const r = s - h * 60 * 60; 5 | const m = Math.floor(r / 60); 6 | const secs = r - m * 60; 7 | return { 8 | hours: h, 9 | mins: m, 10 | secs: secs, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2015", "DOM"], 4 | "module": "ESNext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "target": "ES2015", 8 | "allowSyntheticDefaultImports": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/frontend/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; 2 | import type { TRPC_Router } from "../server/controllers/trpc.js"; 3 | 4 | export const trpc = createTRPCProxyClient({ 5 | links: [ 6 | httpBatchLink({ 7 | url: `/trpc`, 8 | }), 9 | ], 10 | }); 11 | -------------------------------------------------------------------------------- /test/fixtures/organization.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from "knex"; 2 | 3 | export function createOrganization(client: Knex) { 4 | return client("organizations").insert({ 5 | organization_name: "test_org", 6 | organization_email: "test@test.org", 7 | organization_password: "test_password", 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2015", "DOM"], 4 | "module": "ESNext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "target": "ES2015", 8 | "allowSyntheticDefaultImports": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2015", "DOM"], 4 | "module": "ESNext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "target": "ES2015", 8 | "allowSyntheticDefaultImports": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/context/context.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from "knex"; 2 | import { OrganzationsActions } from "./actions/organizations"; 3 | 4 | export class Context { 5 | knex: Knex; 6 | 7 | constructor(_knex: Knex) { 8 | this.knex = _knex; 9 | } 10 | 11 | get organzations() { 12 | return new OrganzationsActions(this); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/server/errors.ts: -------------------------------------------------------------------------------- 1 | export class OrganizationExistsError extends Error { 2 | constructor(email: string) { 3 | super(`Organization with email ${email} already exists.`); 4 | } 5 | } 6 | 7 | export class InvalidCredentialsError extends Error { 8 | constructor() { 9 | super(`Email or password is incorrect.`); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/frontend/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { createRouter } from "./router.js"; 3 | import { VueQueryPlugin } from "@tanstack/vue-query"; 4 | import App from "./App.vue"; 5 | import "./style.css"; 6 | 7 | const app = createApp(App); 8 | const router = createRouter(); 9 | 10 | app.use(router); 11 | app.use(VueQueryPlugin); 12 | app.mount("#root"); 13 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/frontend/composables/loading.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | 3 | export function useDisableWhileExecuting() { 4 | const loading = ref(false); 5 | 6 | async function disableWhileRunning(task: () => Promise) { 7 | loading.value = true; 8 | await task(); 9 | loading.value = false; 10 | } 11 | 12 | return { 13 | disableWhileRunning, 14 | loading, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Dependency Notifier 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /cypress/tasks/resetdb.ts: -------------------------------------------------------------------------------- 1 | import { resetdb, runAllMigrations } from "../../scripts/utils.js"; 2 | import debugLib from "debug"; 3 | 4 | const debug = debugLib("neith:cypress:task:resetdb"); 5 | 6 | export async function scaffoldDatabase(): Promise { 7 | debug("resetting db..."); 8 | const dbname = await resetdb("notifier_test"); 9 | debug("running migrations..."); 10 | await runAllMigrations(dbname); 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /src/frontend/components/Card.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 20 | -------------------------------------------------------------------------------- /src/fetchVersions.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | const registry = "https://registry.npmjs.org"; 4 | 5 | async function fetchJson(url: string) { 6 | const res = await fetch(url, { 7 | headers: { 8 | "Content-Type": "application/json", 9 | }, 10 | }); 11 | return await res.json(); 12 | } 13 | 14 | async function fetchLatestVersion(mod: string) { 15 | const json = await fetchJson(`${registry}/${mod}`); 16 | } 17 | 18 | fetchLatestVersion("@vue/test-utils"); 19 | -------------------------------------------------------------------------------- /test/fixtures/job.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from "knex"; 2 | 3 | export function createJob(client: Knex, options: { organization_id: number }) { 4 | return client("jobs").insert({ 5 | job_description: `Default job for for organization ${options.organization_id}`, 6 | job_last_run: null, 7 | job_name: "default_job", 8 | job_schedule: "weekly", 9 | job_starts_at: null, 10 | organization_id: options.organization_id, 11 | timezone: "Australia/Brisbane", 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /scripts/dbToTs.ts: -------------------------------------------------------------------------------- 1 | import { execa } from "./utils.js"; 2 | 3 | async function main() { 4 | const whoami = (await execa("whoami")).trim(); 5 | 6 | try { 7 | const pw = process.env.POSTGRES_PASSWORD 8 | ? `:${process.env.POSTGRES_PASSWORD}` 9 | : ""; 10 | const db = `postgresql://${whoami}${pw}@localhost/${process.env.POSTGRES_DB}`; 11 | await execa(`npx pg-to-ts generate -c ${db} -o dbschema.ts`); 12 | } catch (e) { 13 | console.log(e); 14 | } 15 | } 16 | 17 | main(); 18 | -------------------------------------------------------------------------------- /src/frontend/components/Button.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /src/frontend/components/SideMenu.cy.jsx: -------------------------------------------------------------------------------- 1 | import SideMenu from "./SideMenu.vue"; 2 | const items = [ 3 | { 4 | href: "/", 5 | name: "dependencies", 6 | }, 7 | { 8 | href: "/notifications", 9 | name: "notifications", 10 | }, 11 | { 12 | href: "/account", 13 | name: "account", 14 | }, 15 | ]; 16 | describe("SideMenu", () => { 17 | it("renders", () => { 18 | cy.mount() 19 | .get('[data-cy-selected="true"]') 20 | .contains("dependencies"); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /migrations/20230103105914_addTimezoneToJob.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @param {import('knex').Knex} knex 5 | * @returns {Promise} 6 | */ 7 | export async function up(knex) { 8 | await knex.schema.alterTable("jobs", (table) => { 9 | table.text("timezone").notNullable(); 10 | }); 11 | } 12 | 13 | /** 14 | * @param {import('knex').Knex} knex 15 | * @returns {Promise} 16 | */ 17 | export async function down(knex) { 18 | await knex.schema.alterTable("jobs", (table) => { 19 | return table.dropColumn("timezone"); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/frontend/components/SideMenu.cy.tsx: -------------------------------------------------------------------------------- 1 | import SideMenu from "./SideMenu.vue"; 2 | 3 | const items = [ 4 | { 5 | href: "/", 6 | name: "dependencies", 7 | }, 8 | { 9 | href: "/notifications", 10 | name: "notifications", 11 | }, 12 | { 13 | href: "/account", 14 | name: "account", 15 | }, 16 | ]; 17 | 18 | describe("SideMenu", () => { 19 | it("renders", () => { 20 | cy.mount(() as any) 21 | .get('[data-cy-selected="true"]') 22 | .contains("dependencies"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ModuleInfo { 2 | _id: string; 3 | _rev: string; 4 | name: string; 5 | description: string; 6 | "dist-tags": Record; 7 | time: { 8 | // "1.0.0-beta.10": "2018-01-10T16:33:08.512Z", 9 | [x: string]: string; 10 | }; 11 | versions: Record; 12 | } 13 | 14 | interface ModuleVersion { 15 | name: string; 16 | version: string; 17 | description: string; 18 | repository: { 19 | type: "git" | string; 20 | url: string; 21 | }; 22 | homepage: string; 23 | author: { 24 | name: string; 25 | }; 26 | license: string; 27 | } 28 | -------------------------------------------------------------------------------- /migrations/20230103022425_addEmailsTable.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @param {import('knex').Knex} knex 5 | * @returns {Promise} 6 | */ 7 | export async function up(knex) { 8 | await knex.schema.createTable("emails", (table) => { 9 | table.increments("id"); 10 | table.text("email").notNullable(); 11 | table.integer("organization_id"); 12 | table.foreign("organization_id").references("id").inTable("organizations"); 13 | }); 14 | } 15 | 16 | /** 17 | * @param {import('knex').Knex} knex 18 | * @returns {Promise} 19 | */ 20 | export async function down(knex) { 21 | await knex.schema.dropTable("emails"); 22 | } 23 | -------------------------------------------------------------------------------- /docs/DB.mmd: -------------------------------------------------------------------------------- 1 | erDiagram 2 | organization ||--|{ module : has 3 | organization ||--|| job : has 4 | 5 | organization { 6 | id int 7 | organization_name text 8 | organization_password text 9 | organization_email text 10 | } 11 | 12 | module { 13 | id int 14 | organization_id int 15 | module_name text 16 | notify_when notification_frequency 17 | } 18 | 19 | job { 20 | id int 21 | organization_id int 22 | job_name text 23 | job_description text 24 | job_starts_at timestamp 25 | job_last_run timestamp 26 | job_schedule schedule 27 | } -------------------------------------------------------------------------------- /migrations/20230105104854_addSlackColumnsToOrganizationTable.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @param {import('knex').Knex} knex 5 | * @returns {Promise} 6 | */ 7 | export async function up(knex) { 8 | await knex.schema.alterTable("organizations", (table) => { 9 | table.text("slack_workspace"); 10 | table.text("slack_channel"); 11 | }); 12 | } 13 | 14 | /** 15 | * @param {import('knex').Knex} knex 16 | * @returns {Promise} 17 | */ 18 | export async function down(knex) { 19 | await knex.schema.alterTable("organizations", async (table) => { 20 | table.dropColumn("slack_workspace"); 21 | table.dropColumn("slack_channel"); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /migrations/20221220105733_addOrganizationTable.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @param {import('knex').Knex} knex 5 | * @returns {Promise} 6 | */ 7 | export async function up(knex) { 8 | return knex.schema.createTable("organizations", (table) => { 9 | table.increments("id"); 10 | table.text("organization_name").notNullable().unique(); 11 | table.text("organization_email").notNullable().unique(); 12 | table.text("organization_password").notNullable(); 13 | }); 14 | } 15 | 16 | /** 17 | * @param {import('knex').Knex} knex 18 | * @returns {Promise} 19 | */ 20 | export async function down(knex) { 21 | return knex.schema.dropTable("organizations"); 22 | } 23 | -------------------------------------------------------------------------------- /migrations/20221228123809_addSessionTable.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @param {import('knex').Knex} knex 5 | * @returns {Promise} 6 | */ 7 | export async function up(knex) { 8 | await knex.schema.createTable("sessions", (table) => { 9 | table.text("id").notNullable().unique(); 10 | table.timestamp("created").notNullable().defaultTo(knex.fn.now()); 11 | table.integer("organization_id"); 12 | table.foreign("organization_id").references("id").inTable("organizations"); 13 | }); 14 | } 15 | 16 | /** 17 | * @param {import('knex').Knex} knex 18 | * @returns {Promise} 19 | */ 20 | export async function down(knex) { 21 | await knex.schema.dropTable("sessions"); 22 | } 23 | -------------------------------------------------------------------------------- /src/server/models/session.ts: -------------------------------------------------------------------------------- 1 | import { CookieOptions, Response } from "express"; 2 | import { Knex } from "knex"; 3 | import { randomUUID } from "node:crypto"; 4 | 5 | export class Session { 6 | static COOKIE_ID = "COOKIE"; 7 | 8 | static async create(db: Knex, organzationId?: string) { 9 | const [{ id }] = await db("sessions") 10 | .insert({ 11 | organization_id: organzationId, 12 | id: randomUUID(), 13 | }) 14 | .returning("id"); 15 | 16 | return id; 17 | } 18 | 19 | static makeSessionCookie( 20 | sessionId: string 21 | ): [name: string, val: string, options: CookieOptions] { 22 | return [Session.COOKIE_ID, sessionId, { httpOnly: true }]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | import { startServer, stopServer } from "./cypress/tasks/server.js"; 3 | import { scaffoldDatabase } from "./cypress/tasks/resetdb.js"; 4 | import viteConfig from "./src/frontend/vite.config"; 5 | 6 | export default defineConfig({ 7 | e2e: { 8 | baseUrl: "http://localhost:4444", 9 | async setupNodeEvents(on, config) { 10 | await startServer(); 11 | 12 | on("task", { 13 | startServer, 14 | stopServer, 15 | scaffoldDatabase, 16 | }); 17 | }, 18 | }, 19 | 20 | component: { 21 | devServer: { 22 | framework: "vue", 23 | bundler: "vite", 24 | viteConfig, 25 | }, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/server/views/sign_in.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block content 4 | div(class='w-full p-4 flex justify-center h-full md:pt-32') 5 | div(class='w-full md:max-w-md') 6 | form(method="POST" action="/sign_in" class="bg-white p-2 md:p-8 border shadow-lg rounded flex flex-col") 7 | label(for="email") Email 8 | input#email(name="email" type="email") 9 | 10 | label(for="password") Password 11 | input#password(name="password" type="password") 12 | 13 | button(id='submit' class="flex items-center justify-center hover:bg-fuchsia-500 mt-2 mb-1 text-white md:p-2 rounded-md h-8 w-full md:w-24 md:h-12 bg-fuchsia-600 self-end") Submit 14 | 15 | if flash 16 | include includes/flash.pug -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /src/frontend/components/Loader.vue: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /src/server/models/job.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from "knex"; 2 | import { schedule } from "../../../dbschema.js"; 3 | import debugLib from "debug"; 4 | import { Organization } from "./organization.js"; 5 | 6 | const debug = debugLib("neith:server:models:job"); 7 | 8 | export const Job = { 9 | async updateJobScheduleForOrganization( 10 | db: Knex, 11 | options: { 12 | jobSchedule: schedule; 13 | organizationId: number; 14 | } 15 | ): Promise { 16 | const job = await Organization.getJob(db, { 17 | organizationId: options.organizationId, 18 | }); 19 | 20 | return db("jobs") 21 | .where({ 22 | id: job.id, 23 | }) 24 | .update({ 25 | job_schedule: options.jobSchedule, 26 | }); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/server/services/mailer.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from "nodemailer"; 2 | import debugLib from "debug"; 3 | 4 | const debug = debugLib("neith:server:services:mailer"); 5 | 6 | const transporter = nodemailer.createTransport({ 7 | host: process.env.EMAIL_HOST, 8 | port: 465, 9 | secure: true, 10 | from: "hello@neith.dev", 11 | auth: { 12 | user: process.env.EMAIL_USER, 13 | pass: process.env.EMAIL_PASSWORD, 14 | }, 15 | }); 16 | 17 | export const Mailer = { 18 | sendEmail(text: string) { 19 | const d = new Date(); 20 | debug("Sending email at %s", d.toISOString()); 21 | return transporter.sendMail({ 22 | to: "hello@neith.dev", 23 | from: "hello@neith.dev", 24 | subject: "Testing the dep notifier app!", 25 | text, 26 | }); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/frontend/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter as createVueRouter, createWebHistory } from "vue-router"; 2 | import DependenciesPage from "./views/DependenciesPage.vue"; 3 | import NotificationsPage from "./views/NotificationsPage.vue"; 4 | import AccountPage from "./views/AccountPage.vue"; 5 | 6 | export function createRouter() { 7 | return createVueRouter({ 8 | history: createWebHistory("/app/"), 9 | routes: [ 10 | { 11 | path: "/", 12 | name: "dependencies", 13 | component: DependenciesPage, 14 | }, 15 | { 16 | path: "/notifications", 17 | name: "notifications", 18 | component: NotificationsPage, 19 | }, 20 | { 21 | path: "/account", 22 | name: "account", 23 | component: AccountPage, 24 | }, 25 | ], 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /migrations/20221223095059_addModuleTables.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @param {import('knex').Knex} knex 5 | * @returns {Promise} 6 | */ 7 | export async function up(knex) { 8 | await knex.schema.createTable("modules", (table) => { 9 | table.increments("id"); 10 | table.integer("organization_id").notNullable(); 11 | table.foreign("organization_id").references("id").inTable("organizations"); 12 | table.text("module_name").notNullable().unique(); 13 | table 14 | .enu("notify_when", ["major", "minor", "prerelease"], { 15 | useNative: true, 16 | enumName: "notify_when", 17 | }) 18 | .notNullable(); 19 | }); 20 | } 21 | 22 | /** 23 | * @param {import('knex').Knex} knex 24 | * @returns {Promise} 25 | */ 26 | export async function down(knex) { 27 | await knex.schema.dropTable("modules"); 28 | } 29 | -------------------------------------------------------------------------------- /src/frontend/composables/modal.ts: -------------------------------------------------------------------------------- 1 | import { ref, shallowRef } from "vue"; 2 | import DependenciesForm from "../views/DependenciesPage/DependencyForm.vue"; 3 | import EmailForm from "../views/NotificationsPage/EmailForm.vue"; 4 | 5 | const show = ref(false); 6 | const component = shallowRef(); 7 | 8 | export function useModal() { 9 | return { 10 | show, 11 | component, 12 | showModal: async (type: "dependenciesForm" | "emailForm") => { 13 | show.value = true; 14 | switch (type) { 15 | case "dependenciesForm": 16 | return (component.value = DependenciesForm); 17 | case "emailForm": 18 | return (component.value = EmailForm); 19 | default: 20 | throw new Error(`Unknown modal ${type}`); 21 | } 22 | }, 23 | hideModal: () => { 24 | show.value = false; 25 | }, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import gulp from "gulp"; 3 | import { spawn } from "node:child_process"; 4 | 5 | /** @type {Array>} */ 6 | let serverP = []; 7 | 8 | async function server() { 9 | for (const p of serverP) { 10 | p.kill(); 11 | } 12 | 13 | const s = spawn(`npm run server`, { 14 | shell: true, 15 | stdio: "inherit", 16 | env: process.env, 17 | }); 18 | 19 | s.on("error", console.error); 20 | 21 | const t = spawn( 22 | `npx tailwindcss -i ./src/server/tailwind.css --config ./tailwind.server.config.cjs --watch -o ./src/server/style.css`, 23 | { shell: true, stdio: "inherit" } 24 | ); 25 | 26 | serverP.push(s, t); 27 | } 28 | 29 | async function vite() { 30 | const s = spawn(`npm run vite:dev`, { 31 | shell: true, 32 | stdio: "inherit", 33 | env: process.env, 34 | }); 35 | } 36 | 37 | gulp.task("dev", gulp.parallel(server, vite)); 38 | -------------------------------------------------------------------------------- /src/server/controllers/html.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import path from "node:path"; 3 | import { requiresAuth } from "../middleware/requiresAuth.js"; 4 | import { Html } from "../models/html.js"; 5 | import url from "node:url"; 6 | 7 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 8 | 9 | export const html = Router(); 10 | 11 | html.get("/", (req, res) => { 12 | res.render("index"); 13 | }); 14 | 15 | html.get("/style.css", (_req, res) => { 16 | res.sendFile(path.join(__dirname, "..", "style.css")); 17 | }); 18 | 19 | html.get("/tailwind.components.css", (_req, res) => { 20 | res.sendFile(path.join(__dirname, "..", "tailwind.components.css")); 21 | }); 22 | 23 | html.get("/app*", requiresAuth, (_req, res) => { 24 | if (process.env.NODE_ENV === "development") { 25 | res.send(Html.appDev()).end(); 26 | } else { 27 | res.send(Html.appProd()).end(); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/frontend/components/Input.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 34 | -------------------------------------------------------------------------------- /src/frontend/components/SideMenu.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 34 | -------------------------------------------------------------------------------- /src/frontend/components/PkgInfo.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 33 | -------------------------------------------------------------------------------- /migrations/20221224052443_addJobTable.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @param {import('knex').Knex} knex 5 | * @returns {Promise} 6 | */ 7 | export async function up(knex) { 8 | await knex.schema.createTable("jobs", (table) => { 9 | table.increments("id").notNullable(); 10 | table.integer("organization_id").notNullable(); 11 | table.foreign("organization_id").references("id").inTable("organizations"); 12 | table.text("job_name"); 13 | table.text("job_description"); 14 | table.timestamp("job_starts_at"); 15 | table.timestamp("job_last_run"); 16 | table 17 | .enu("job_schedule", ["daily", "weekly"], { 18 | useNative: true, 19 | enumName: "schedule", 20 | }) 21 | .notNullable(); 22 | }); 23 | } 24 | 25 | /** 26 | * @param {import('knex').Knex} knex 27 | * @returns {Promise} 28 | */ 29 | export async function down(knex) { 30 | await knex.schema.dropTable("jobs"); 31 | await knex.schema.raw(`drop type schedule;`); 32 | } 33 | -------------------------------------------------------------------------------- /test/migrations/20221220105733_addOrganizationTable.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "vitest"; 2 | import { testMigration } from "./utils"; 3 | 4 | testMigration("20221220105733_addOrganizationTable", (verify) => { 5 | verify.up(async (client) => { 6 | await client("organizations").insert({ 7 | organization_name: "test_org", 8 | organization_email: "test@test.org", 9 | organization_password: "test_password", 10 | }); 11 | 12 | const result = await client("organizations").where({ 13 | organization_name: "test_org", 14 | }); 15 | 16 | expect(result).toEqual([ 17 | { 18 | id: 1, 19 | organization_name: "test_org", 20 | organization_email: "test@test.org", 21 | organization_password: "test_password", 22 | }, 23 | ]); 24 | }); 25 | 26 | verify.down(async (client) => { 27 | try { 28 | await client("organizations").count({ count: "*" }); 29 | } catch (e) { 30 | expect(e.message).toContain('relation "organizations" does not exist'); 31 | } 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/context/actions/organizations.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "../context"; 2 | import assert from "assert"; 3 | import type { 4 | Organizations, 5 | OrganizationModules, 6 | Modules, 7 | ModuleVersions, 8 | } from "../../../dbschema.js"; 9 | 10 | export class OrganzationsActions { 11 | #ctx: Context; 12 | 13 | constructor(ctx: Context) { 14 | this.#ctx = ctx; 15 | } 16 | 17 | async queryModulesForOrganzations(orgId: number) { 18 | const res = (await this.#ctx 19 | .knex("organizations") 20 | .join( 21 | "organization_modules", 22 | "organization_modules.organization_id", 23 | "=", 24 | "organizations.id" 25 | ) 26 | .join("modules", "organization_modules.module_id", "=", "modules.id") 27 | .join("module_versions", "modules.id", "=", "module_versions.module_id") 28 | .where("organizations.id", orgId)) as Array< 29 | Organizations & OrganizationModules & Modules & ModuleVersions 30 | >; 31 | 32 | return res.map((row) => { 33 | return {}; 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/server/views/sign_up.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block content 4 | div(class='w-full p-4 bg-white flex justify-center h-full md:pt-32') 5 | div(class='w-full md:max-w-md') 6 | form(method="POST" action="/sign_up" class="bg-white p-2 md:px-8 md:pt-8 md:pb-4 shadow-lg border rounded flex flex-col") 7 | input#timezone(name="timezone" type="hidden") 8 | 9 | label(for="email") Email 10 | input#email(name="email" type="email") 11 | 12 | label(for="organization") Organization Name 13 | input#organization(name="organization") 14 | 15 | label(for="password") Password 16 | input#password(name="password" type="password") 17 | 18 | button(id='submit' class="flex items-center justify-center hover:bg-fuchsia-500 mt-2 mb-1 text-white md:p-2 rounded-md h-8 w-full md:w-24 md:h-12 bg-fuchsia-600 self-end") Submit 19 | 20 | if flash 21 | include includes/flash.pug 22 | 23 | script. 24 | const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone 25 | document.querySelector("#timezone").value = timezone 26 | -------------------------------------------------------------------------------- /src/server/models/html.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import url from "node:url"; 3 | import path from "node:path"; 4 | 5 | let prodHtml = ""; 6 | 7 | if (process.env.NODE_ENV === "production") { 8 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 9 | prodHtml = fs.readFileSync( 10 | path.join(__dirname, "..", "..", "frontend", "dist", "index.html"), 11 | "utf-8" 12 | ); 13 | } 14 | 15 | export const Html = { 16 | appDev() { 17 | const html = ` 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Dependency Notifier 28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | `; 36 | 37 | return html; 38 | }, 39 | 40 | appProd() { 41 | return prodHtml; 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /test/migrations/20230103105914_addTimezoneToJob.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "vitest"; 2 | import { createOrganization } from "../fixtures/organization"; 3 | import { testMigration } from "./utils"; 4 | 5 | testMigration("20230103105914_addTimezoneToJob", (verify) => { 6 | verify.up(async (client) => { 7 | const [{ id: orgId }] = await createOrganization(client).returning("id"); 8 | 9 | await client("jobs").insert({ 10 | organization_id: orgId, 11 | job_name: "test job", 12 | job_description: "this is a test job", 13 | job_starts_at: "2021-01-07T12:30:00.000Z", 14 | timezone: "Australia/Brisbane", 15 | job_schedule: "daily", 16 | }); 17 | 18 | const [result] = await client("jobs") 19 | .where({ 20 | timezone: "Australia/Brisbane", 21 | }) 22 | .count("*"); 23 | 24 | expect(result).toEqual({ count: "1" }); 25 | }); 26 | 27 | verify.down(async (client) => { 28 | try { 29 | await client("jobs").select("timezone"); 30 | } catch (e: any) { 31 | expect(e.message).toContain( 32 | 'select "timezone" from "jobs" - column "timezone" does not exist' 33 | ); 34 | } 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/frontend/views/DependenciesPage/DependencyForm.cy.jsx: -------------------------------------------------------------------------------- 1 | import DependencyForm from "./DependencyForm.vue"; 2 | const trpcResponse = { 3 | name: "vite", 4 | description: "Native-ESM powered web dev build tool", 5 | tags: [ 6 | { name: "latest", tag: "4.0.3", published: "2022-12-21T13:43:00.084Z" }, 7 | { 8 | name: "beta", 9 | tag: "4.0.0-beta.7", 10 | published: "2022-12-08T22:34:44.406Z", 11 | }, 12 | { 13 | name: "alpha", 14 | tag: "4.0.0-alpha.6", 15 | published: "2022-11-30T16:54:51.503Z", 16 | }, 17 | ], 18 | }; 19 | describe("", () => { 20 | it("renders", () => { 21 | cy.intercept("http://localhost:4444/trpc/getDependencies*", { 22 | body: [ 23 | { 24 | result: { 25 | data: trpcResponse, 26 | }, 27 | }, 28 | ], 29 | }); 30 | cy.mount(() => ( 31 |
32 | {/* @ts-ignore - dunno, figure it out */} 33 | 34 |
35 | )) 36 | .get("input") 37 | .type("vite"); 38 | cy.get("[data-cy='pkg-info']").contains( 39 | "Native-ESM powered web dev build tool" 40 | ); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/migrations/20221228123809_addSessionTable.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "vitest"; 2 | import { testMigration } from "./utils"; 3 | 4 | testMigration("20221228123809_addSessionTable", (verify) => { 5 | verify.up(async (client) => { 6 | const [{ id: orgId }] = await client("organizations") 7 | .insert({ 8 | organization_name: "test_org", 9 | organization_email: "test@test.org", 10 | organization_password: "test_password", 11 | }) 12 | .returning("id"); 13 | 14 | await client("sessions") 15 | .insert({ 16 | id: "aaa-bbb", 17 | 18 | created: "2022-12-28T12:42:58.499Z", 19 | organization_id: orgId, 20 | }) 21 | .returning("id"); 22 | 23 | const result = await client("sessions").first(); 24 | 25 | expect(result).toEqual({ 26 | created: new Date("2022-12-28T12:42:58.499Z"), 27 | id: "aaa-bbb", 28 | organization_id: orgId, 29 | }); 30 | }); 31 | 32 | verify.down(async (client) => { 33 | expect.assertions(1); 34 | 35 | try { 36 | await client("sessions").count({ count: "*" }); 37 | } catch (e: any) { 38 | expect(e.message).toContain(`relation "sessions" does not exist`); 39 | } 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/server/session.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, NextFunction } from "express"; 2 | import { randomUUID } from "node:crypto"; 3 | 4 | const COOKIE = "COOKIE"; 5 | 6 | export async function sessionMiddleware( 7 | req: Request, 8 | res: Response, 9 | next: NextFunction 10 | ) { 11 | const sessionId = req.cookies[COOKIE]; 12 | 13 | if (!sessionId) { 14 | const id = randomUUID(); 15 | await req.db("sessions").insert({ id }); 16 | 17 | req.session = { id }; 18 | res.cookie(COOKIE, id); 19 | 20 | return next(); 21 | } 22 | 23 | const session = await req 24 | .db("sessions") 25 | .where("id", sessionId) 26 | .first() 27 | .returning<{ id: string; organization_id: string }>([ 28 | "id", 29 | "organization_id", 30 | ]); 31 | 32 | if (sessionId && session) { 33 | req.session = { 34 | id: session.id, 35 | organizationId: parseInt(session.organization_id, 10), 36 | }; 37 | res.locals.organizationId = session.organization_id; 38 | return next(); 39 | } 40 | 41 | const [{ id }] = await req 42 | .db("sessions") 43 | .insert({ id: randomUUID() }) 44 | .returning("id"); 45 | req.session = { id }; 46 | res.cookie(COOKIE, id); 47 | 48 | next(); 49 | } 50 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | import "../../src/frontend/style.css"; 19 | 20 | // Alternatively you can use CommonJS syntax: 21 | // require('./commands') 22 | 23 | import { mount } from "cypress/vue"; 24 | 25 | // Augment the Cypress namespace to include type definitions for 26 | // your custom command. 27 | // Alternatively, can be defined in cypress/support/component.d.ts 28 | // with a at the top of your spec. 29 | declare global { 30 | namespace Cypress { 31 | interface Chainable { 32 | mount: typeof mount; 33 | } 34 | } 35 | } 36 | 37 | Cypress.Commands.add("mount", mount); 38 | 39 | // Example use: 40 | // cy.mount(MyComponent) 41 | -------------------------------------------------------------------------------- /src/frontend/views/DependenciesPage/DependencyForm.cy.tsx: -------------------------------------------------------------------------------- 1 | import type { GetDependency } from "../../../server/models/registry"; 2 | import DependencyForm from "./DependencyForm.vue"; 3 | 4 | const trpcResponse: GetDependency = { 5 | name: "vite", 6 | description: "Native-ESM powered web dev build tool", 7 | tags: [ 8 | { name: "latest", tag: "4.0.3", published: "2022-12-21T13:43:00.084Z" }, 9 | { 10 | name: "beta", 11 | tag: "4.0.0-beta.7", 12 | published: "2022-12-08T22:34:44.406Z", 13 | }, 14 | { 15 | name: "alpha", 16 | tag: "4.0.0-alpha.6", 17 | published: "2022-11-30T16:54:51.503Z", 18 | }, 19 | ], 20 | }; 21 | 22 | describe("", () => { 23 | it("renders", () => { 24 | cy.intercept("http://localhost:4444/trpc/getDependencies*", { 25 | body: [ 26 | { 27 | result: { 28 | data: trpcResponse, 29 | }, 30 | }, 31 | ], 32 | }); 33 | 34 | cy.mount(() => ( 35 |
36 | {/* @ts-ignore - dunno, figure it out */} 37 | 38 |
39 | )) 40 | .get("input") 41 | .type("vite"); 42 | 43 | cy.get("[data-cy='pkg-info']").contains( 44 | "Native-ESM powered web dev build tool" 45 | ); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/frontend/views/NotificationsPage/EmailForm.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 48 | -------------------------------------------------------------------------------- /src/server/models/registry.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | import { isoToUtc } from "../../notify.js"; 3 | import type { ModuleInfo } from "../../types.js"; 4 | 5 | const NPM = "https://registry.npmjs.org"; 6 | 7 | export type GetDependency = Awaited< 8 | ReturnType 9 | >; 10 | 11 | export interface NpmPkg { 12 | name: string; 13 | description: string; 14 | tags: Array<{ 15 | name: string; 16 | tag: string; 17 | published: string; 18 | }>; 19 | } 20 | 21 | export const Registry = { 22 | async fetchFromRegistry(pkg: string) { 23 | const res = await fetch(`${NPM}/${pkg}`, { 24 | headers: { 25 | "Content-Type": "application/json", 26 | }, 27 | }); 28 | return (await res.json()) as unknown as ModuleInfo; 29 | }, 30 | 31 | async fetchPackage(pkg: string): Promise { 32 | const result = await this.fetchFromRegistry(pkg); 33 | const tags = Object.entries(result["dist-tags"]) 34 | .map(([name, tag]) => ({ 35 | name, 36 | tag, 37 | published: result.time[tag], 38 | })) 39 | .map((x) => x) 40 | .sort((x, y) => (isoToUtc(x.published) < isoToUtc(y.published) ? 1 : -1)) 41 | .slice(0, 10); 42 | 43 | return { 44 | name: result.name, 45 | description: result.description, 46 | tags, 47 | }; 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "node:child_process"; 2 | import knex from "knex"; 3 | import knexConfig from "../knexfile.js"; 4 | import debugLib from "debug"; 5 | 6 | const debug = debugLib("neith:scripts:utils"); 7 | 8 | export async function execa(cmd: string, env: Record = {}) { 9 | debug("running cmd %s", cmd); 10 | return new Promise((resolve, reject) => { 11 | exec( 12 | cmd, 13 | { 14 | env: { ...process.env, ...env }, 15 | }, 16 | (err, stdout) => { 17 | if (err) { 18 | debug("error running %s: %s", cmd, err.message); 19 | reject(err); 20 | } 21 | resolve(stdout); 22 | } 23 | ); 24 | }); 25 | } 26 | 27 | const TEST_DB = "notifier_test"; 28 | 29 | export async function resetdb(name?: string) { 30 | name ??= `${TEST_DB}_${(Math.random() * 10000).toFixed(0)}`; 31 | debug(`Creating db: ${name}`); 32 | try { 33 | await execa(`createdb ${name}`); 34 | } catch (e: any) { 35 | // 36 | } 37 | 38 | return name; 39 | } 40 | 41 | export function createKnex(name: string) { 42 | return knex({ 43 | ...knexConfig, 44 | connection: { ...knexConfig.connection, database: name }, 45 | }); 46 | } 47 | 48 | export async function runAllMigrations(dbname: string) { 49 | await execa(`npm run db:test:migrate:all`, { 50 | POSTGRES_DB: dbname, 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /docs/STORIES.md: -------------------------------------------------------------------------------- 1 | # Stories 2 | 3 | High level overview of some of the things a user can do. 4 | 5 | ## Spontaneous Notifications 6 | 7 | - The current version of Vite is 3.9.0 8 | - User is subscribed to all major + all pre-releases 9 | - Vite v4.0.0-alpha.1 appears on npm 10 | - The user receives an email and slack notification immediately 11 | - "Vite has a new pre-release available. The current major version, `latest`, is 3.9.0. 4.0.0-alpha.1 is now available. " 12 | 13 | ## Batched Notifications 14 | 15 | - A user is subscribed to Vue and Vite, minor versions only 16 | - They want an email, once a week, on Monday at 9:00am 17 | - On Wednesday, a new minor is released for Vue (3.1.0) 18 | - On Friday, a new minor is released for Vite (4.1.0) 19 | - On Saturday, a new minor is released for Vite (4.2.0) 20 | - On Monday morning, the user receives ane email containing information about the latest version of both minors (Vue 3.1.0 and Vite 4.2.0) 21 | - Vite 4.1.0's notification is obviated by 4.2.0 22 | 23 | ## Notifications 24 | 25 | ### Supported Types 26 | 27 | For the MVP, I'd like to support two types of notifications: 28 | 29 | - Email (can add as many emails as you like) 30 | - Push (for now, just Slack. [Docs](https://api.slack.com/messaging/webhooks)) 31 | 32 | ### Frequency 33 | 34 | For the initial release, I'd like to support 35 | 36 | - Fine grained (per-dependency) 37 | - Scheduled (Daily, Weekly) 38 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | -------------------------------------------------------------------------------- /test/migrations/20230103022425_addEmailsTable.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "vitest"; 2 | import { testMigration } from "./utils"; 3 | 4 | testMigration("20230103022425_addEmailsTable", (verify) => { 5 | verify.up(async (client) => { 6 | const [{ id: orgId }] = await client("organizations") 7 | .insert({ 8 | organization_name: "test_org", 9 | organization_email: "test@test.org", 10 | organization_password: "test_password", 11 | }) 12 | .returning("id"); 13 | 14 | await client("emails").insert({ 15 | email: "somebody@example.com", 16 | organization_id: orgId, 17 | }); 18 | 19 | const result = await client("organizations") 20 | .join("emails", "organizations.id", "=", "emails.organization_id") 21 | .where("emails.organization_id", orgId); 22 | 23 | expect(result).toMatchInlineSnapshot(` 24 | [ 25 | { 26 | "email": "somebody@example.com", 27 | "id": 1, 28 | "organization_email": "test@test.org", 29 | "organization_id": 1, 30 | "organization_name": "test_org", 31 | "organization_password": "test_password", 32 | }, 33 | ] 34 | `); 35 | }); 36 | 37 | verify.down(async (client) => { 38 | expect.assertions(1); 39 | 40 | try { 41 | await client("emails").count({ count: "*" }); 42 | } catch (e: any) { 43 | expect(e.message).toContain(`relation "emails" does not exist`); 44 | } 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /cypress/e2e/authentication.cy.ts: -------------------------------------------------------------------------------- 1 | const rand = () => (Math.random() * 100000).toFixed(); 2 | function randomEmail() { 3 | return `${rand()}@${rand()}.com`; 4 | } 5 | 6 | function randomOrg() { 7 | return `Org #${rand()}`; 8 | } 9 | 10 | describe("authentication", () => { 11 | beforeEach(() => { 12 | cy.task("stopServer"); 13 | cy.task("scaffoldDatabase"); 14 | cy.task("startServer"); 15 | }); 16 | 17 | it("signs up a new user", () => { 18 | cy.visit("/"); 19 | cy.get("a").contains("Sign Up").click(); 20 | cy.get('[name="email"]').type(randomEmail()); 21 | cy.get('[name="organization"]').type(randomOrg()); 22 | cy.get('[name="password"]').type("password123"); 23 | cy.get("button").contains("Submit").click(); 24 | cy.url().should("equal", "http://localhost:4444/app"); 25 | }); 26 | 27 | it("fails to sign up due to duplicate credentials", () => { 28 | const email = randomEmail(); 29 | function signup() { 30 | cy.get('[name="email"]').type(email); 31 | cy.get('[name="organization"]').type(randomOrg()); 32 | cy.get('[name="password"]').type("password123"); 33 | cy.get("button").contains("Submit").click(); 34 | } 35 | cy.visit("/"); 36 | cy.get("a").contains("Sign Up").click(); 37 | signup(); 38 | cy.url().should("equal", "http://localhost:4444/app"); 39 | cy.visit("/sign_up"); 40 | signup(); 41 | cy.get('[role="alert"]').contains( 42 | `Organization with email ${email} already exists.` 43 | ); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /docs/JOBS.md: -------------------------------------------------------------------------------- 1 | # Jobs 2 | 3 | Our core functionality depends on executing jobs on a user defined schedule. 4 | 5 | ## Running Jobs 6 | 7 | There are many options for this. Thread: https://twitter.com/Lachlan19900/status/1606497731954225157 8 | 9 | ### Cron 10 | 11 | The classic unix utility, cron. We probably want to use some kind of JS wrapper, though. It's more expressive. 12 | 13 | ### Node.js Packages 14 | 15 | - node-cron: https://www.npmjs.com/package/cron 16 | - node-reqsue: https://github.com/actionhero/node-resque# 17 | - node-schedule: https://github.com/node-schedule/node-schedule 18 | - agenda: https://github.com/agenda/agenda 19 | - bree: https://github.com/breejs/bree 20 | 21 | TODO: Evaluate options. 22 | 23 | ### Other 24 | 25 | - pg_cron: https://github.com/citusdata/pg_cron 26 | - cron on GitHub Actions 27 | - pg-boss: https://github.com/timgit/pg-boss 28 | 29 | ### Managed Service 30 | 31 | There are many services that handle jobs. The usual culprits: AWS, Cloudflare, etc. There are also smaller players, most of which are built on the bigger places, such as Modal, etc. 32 | 33 | These are probably good options as we scale up, but I'd like to explore managing jobs myself at first, for learning purposes and for flexibility. 34 | 35 | Solutions: 36 | 37 | - https://temporal.io/ 38 | 39 | ## Source of Truth 40 | 41 | Regardless of what library I use, I'd like to have the source of truth be my database. We can persist the description of the jobs there, and easily change the job running library/service later. 42 | -------------------------------------------------------------------------------- /test/migrations/20230105104854_addSlackColumnsToOrganizationTable.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "vitest"; 2 | import { createOrganization } from "../fixtures/organization"; 3 | import { testMigration } from "./utils"; 4 | 5 | testMigration("20230105104854_addSlackColumnsToOrganizationTable", (verify) => { 6 | verify.up(async (client) => { 7 | await createOrganization(client); 8 | await client("organizations").where({ id: 1 }).update({ 9 | slack_workspace: "workspace", 10 | slack_channel: "dev", 11 | }); 12 | 13 | const result = await client("organizations").where({ 14 | id: 1, 15 | }); 16 | 17 | expect(result).toEqual([ 18 | { 19 | id: 1, 20 | organization_name: "test_org", 21 | organization_email: "test@test.org", 22 | organization_password: "test_password", 23 | slack_workspace: "workspace", 24 | slack_channel: "dev", 25 | }, 26 | ]); 27 | }); 28 | 29 | verify.down(async (client) => { 30 | try { 31 | await client("organizations").select(["slack_workspace"]); 32 | } catch (e: any) { 33 | expect(e.message).toContain( 34 | 'select "slack_workspace" from "organizations" - column "slack_workspace" does not exist' 35 | ); 36 | } 37 | 38 | try { 39 | await client("organizations").select(["slack_channel"]); 40 | } catch (e: any) { 41 | expect(e.message).toContain( 42 | 'select "slack_channel" from "organizations" - column "slack_channel" does not exist' 43 | ); 44 | } 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/migrations/20221223095059_addModuleTables.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "vitest"; 2 | import { testMigration } from "./utils"; 3 | 4 | testMigration("20221223095059_addModuleTables", (verify) => { 5 | verify.up(async (client) => { 6 | const [{ id: orgId }] = await client("organizations") 7 | .insert({ 8 | organization_name: "test_org", 9 | organization_email: "test@test.org", 10 | organization_password: "test_password", 11 | }) 12 | .returning("id"); 13 | 14 | await client("modules") 15 | .insert({ 16 | module_name: "vite", 17 | organization_id: orgId, 18 | notify_when: "major", 19 | }) 20 | .returning("id"); 21 | 22 | const result = await client("organizations") 23 | .join("modules", "organizations.id", "=", "modules.organization_id") 24 | .where("organizations.id", orgId); 25 | 26 | expect(result).toMatchInlineSnapshot(` 27 | [ 28 | { 29 | "id": 1, 30 | "module_name": "vite", 31 | "notify_when": "major", 32 | "organization_email": "test@test.org", 33 | "organization_id": 1, 34 | "organization_name": "test_org", 35 | "organization_password": "test_password", 36 | }, 37 | ] 38 | `); 39 | }); 40 | 41 | verify.down(async (client) => { 42 | expect.assertions(1); 43 | 44 | try { 45 | await client("modules").count({ count: "*" }); 46 | } catch (e: any) { 47 | expect(e.message).toContain(`relation "modules" does not exist`); 48 | } 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/frontend/App.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 60 | -------------------------------------------------------------------------------- /test/server/models/package.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from "vitest"; 2 | import { 3 | createKnex, 4 | resetdb, 5 | runAllMigrations, 6 | } from "../../../scripts/utils.js"; 7 | import { Package } from "../../../src/server/models/package.js"; 8 | import { createOrganization } from "../../fixtures/organization.js"; 9 | 10 | describe("saveModuleForOrganization", () => { 11 | let dbname: string; 12 | beforeEach(async () => { 13 | dbname = await resetdb(); 14 | await runAllMigrations(dbname); 15 | }); 16 | 17 | it("saves a new package", async () => { 18 | const client = createKnex(dbname); 19 | const [{ id }] = await createOrganization(client).returning(["id"]); 20 | 21 | await Package.saveModuleForOrganization(client, { 22 | name: "vite", 23 | notify: "major", 24 | organizationId: id, 25 | }); 26 | 27 | const actual = await client("modules") 28 | .where({ 29 | organization_id: id, 30 | }) 31 | .first(); 32 | 33 | expect(actual).toEqual({ 34 | id: 1, 35 | organization_id: 1, 36 | module_name: "vite", 37 | notify_when: "major", 38 | }); 39 | 40 | await Package.saveModuleForOrganization(client, { 41 | name: "vite", 42 | notify: "minor", 43 | organizationId: id, 44 | }); 45 | 46 | const updated = await client("modules") 47 | .where({ 48 | organization_id: id, 49 | }) 50 | .first(); 51 | 52 | expect(updated).toEqual({ 53 | id: 1, 54 | organization_id: 1, 55 | module_name: "vite", 56 | notify_when: "minor", 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/migrations/20221224052443_addJobTable.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "vitest"; 2 | import { testMigration } from "./utils"; 3 | 4 | testMigration("20221224052443_addJobTable", (verify) => { 5 | verify.up(async (client) => { 6 | const [{ id: orgId }] = await client("organizations") 7 | .insert({ 8 | organization_name: "test_org", 9 | organization_email: "test@test.org", 10 | organization_password: "test_password", 11 | }) 12 | .returning("id"); 13 | 14 | await client("jobs").insert({ 15 | organization_id: orgId, 16 | job_name: "test job", 17 | job_description: "this is a test job", 18 | job_starts_at: "2021-01-07T12:30:00.000Z", 19 | job_schedule: "daily", 20 | }); 21 | 22 | const result = await client("jobs").first(); 23 | 24 | expect(result).toMatchInlineSnapshot(` 25 | { 26 | "id": 1, 27 | "job_description": "this is a test job", 28 | "job_last_run": null, 29 | "job_name": "test job", 30 | "job_schedule": "daily", 31 | "job_starts_at": 2021-01-07T12:30:00.000Z, 32 | "organization_id": 1, 33 | } 34 | `); 35 | }); 36 | 37 | verify.down(async (client) => { 38 | expect.assertions(2); 39 | 40 | try { 41 | await client("jobs").count({ count: "*" }); 42 | } catch (e: any) { 43 | expect(e.message).toContain(`relation "jobs" does not exist`); 44 | } 45 | 46 | try { 47 | await client.raw(`select enum_range(null::schedule);`); 48 | } catch (e: any) { 49 | expect(e.message).toContain(`type "schedule" does not exist`); 50 | } 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/server/models/organization.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from "vitest"; 2 | import { 3 | createKnex, 4 | resetdb, 5 | runAllMigrations, 6 | } from "../../../scripts/utils.js"; 7 | import { Organization } from "../../../src/server/models/organization.js"; 8 | import { createJob } from "../../fixtures/job.js"; 9 | import { createOrganization } from "../../fixtures/organization.js"; 10 | 11 | describe("Organization", () => { 12 | let dbname: string; 13 | beforeEach(async () => { 14 | dbname = await resetdb(); 15 | await runAllMigrations(dbname); 16 | }); 17 | 18 | describe("getAllWithJobs", () => { 19 | it("returns all orgs with associated jobs", async () => { 20 | const db = createKnex(dbname); 21 | await createOrganization(db); 22 | await createJob(db, { organization_id: 1 }); 23 | 24 | const actual = await Organization.getAllWithJobs(db); 25 | 26 | expect(actual).toMatchInlineSnapshot(` 27 | [ 28 | { 29 | "id": 1, 30 | "job_description": "Default job for for organization 1", 31 | "job_last_run": null, 32 | "job_name": "default_job", 33 | "job_schedule": "weekly", 34 | "job_starts_at": null, 35 | "organization_email": "test@test.org", 36 | "organization_id": 1, 37 | "organization_name": "test_org", 38 | "organization_password": "test_password", 39 | "slack_channel": null, 40 | "slack_workspace": null, 41 | "timezone": "Australia/Brisbane", 42 | }, 43 | ] 44 | `); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/frontend/components/TrashIcon.vue: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /src/server/controllers/slack.ts: -------------------------------------------------------------------------------- 1 | // Add to Slack 2 | import { Router } from "express"; 3 | import debugLib from "debug"; 4 | import { requiresAuth } from "../middleware/requiresAuth.js"; 5 | 6 | const debug = debugLib("neith:server:controllers:auth"); 7 | 8 | export const slack = Router(); 9 | 10 | slack.get("/slack", requiresAuth, (_req, res) => { 11 | debug('callback from slack authentication. body %o params %s url %s', _req.body, _req.params, _req.url) 12 | res.redirect('/app/notifications') 13 | }); 14 | -------------------------------------------------------------------------------- /src/server/views/layout.pug: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title Dependency Notifier 4 | meta(charset="UTF-8") 5 | meta(http-equiv="X-UA-Compatible" content="IE=edge") 6 | meta(name="viewport" content="width=device-width, initial-scale=1.0") 7 | link(rel="stylesheet" href="../style.css") 8 | body(class='flex flex-col items-center h-full') 9 | header(class="flex justify-center md:py-6 bg-zinc-100 w-full") 10 | div(class="max-w-3xl flex justify-between w-full p-4 md:p-0") 11 | h1(class="text-fuchsia-600 text-3xl md:text-5xl") 12 | a(href="/" class="border-0") NEITH 13 | div(class="flex items-center") 14 | if locals.organizationId 15 | a(href="/app" class="flex items-center justify-center hover:bg-fuchsia-500 text-white md:p-2 rounded-md h-8 w-16 md:w-24 md:h-12 bg-fuchsia-600") Go to App 16 | else 17 | a(href="/sign_in" class="flex items-center justify-center md:bg-white text-fuchsia-600 md:p-2 md:rounded-md h-8 w-16 md:w-24 md:h-12 md:border-2 md:border-fuchsia-600 mx-2 hover:bg-fuchsia-100 border-0") Sign In 18 | a(href="/sign_up" class="flex items-center justify-center hover:bg-fuchsia-500 text-white md:p-2 rounded-md h-8 w-16 md:w-24 md:h-12 bg-fuchsia-600") Sign Up 19 | 20 | //- if locals.organizationId 21 | //- a(href="/app/") Go to App 22 | //- else 23 | //- a(href="/sign_in") Sign In 24 | //- a(href="/sign_up") Sign Up 25 | 26 | block content 27 | 28 | footer(class="flex justify-center py-6 bg-zinc-100 w-full") 29 | div(class="max-w-3xl flex justify-between flex-col md:flex-row items-center md:items-end w-full") 30 | h1(class="text-fuchsia-600 text-4xl mb-4 md:mb-0") NEITH 31 | div 32 | a(href="/" class='ml-4') Docs 33 | a(href="/" class='ml-4') GitHub 34 | a(href="/" class='ml-4') Contact -------------------------------------------------------------------------------- /docs/OVERVIEW.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | This is a basic overview of the product and how it works. 4 | 5 | ## Use Case 6 | 7 | Tools such as Dependabot and Renovate are good at getting the latest stable version of a dependency and making a PR. This is ideal for the majority of projects, where the goal is to keep everything up to date with the latest stable version. 8 | 9 | Some products, especially developer tools, would benefit from knowing when new alpha, beta and pre-releases are published. That way, those products can run their test suites and update their code to be ready for the stable version as soon as it comes out. 10 | 11 | This is the problem we are solving. Users can specify which dependencies they are interested in tracking, and receive a push notification (via email, Slack, or other service) when a new version is available. 12 | 13 | Users can decide which version(s) they are interested in receiving a notification for (release candidate, `next` tag, etc) and how frequently they'd like to be notified. Users can also get a separate notification for each update, or batch notifications (eg, a weekly summary). 14 | 15 | ## How It Works 16 | 17 | We maintain a database of dependencies the user is interested in. Let's say the user is interested in `vite`. At some interval (say daily, the user can decide) we poll the npm registry and grab the versions. [The list can be seen here](https://www.npmjs.com/package/vite?activeTab=versions). 18 | 19 | If the user wants updates for every `alpha`, and a new one has been published since we last checked (eg, `4.0.0-alpha.7` -> `4.0.0-alpha.8`) we update the database, and notify the user (if they want real time notifications) or add it to their upcoming notification (for batched notifications). 20 | 21 | ## Design 22 | 23 | I am using Figma for design. The low fidelity designs are [publicly viewable](https://www.figma.com/file/dmrXJfBYLahzOrl9lt3y3o/Dependency-Notifier-App?node-id=0%3A1&t=lGyDFDh5qEk4K2fC-1). 24 | -------------------------------------------------------------------------------- /src/server/models/package.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from "knex"; 2 | import { Modules, notify_when } from "../../../dbschema.js"; 3 | import debugLib from "debug"; 4 | 5 | const debug = debugLib("neith:server:models:package"); 6 | 7 | export const Package = { 8 | async getModulesForOrganization( 9 | db: Knex, 10 | options: { organizationId: number } 11 | ): Promise { 12 | return db("modules").where({ 13 | organization_id: options.organizationId, 14 | }); 15 | }, 16 | 17 | async delete( 18 | db: Knex, 19 | options: { 20 | organizationId: number; 21 | moduleName: string; 22 | } 23 | ): Promise { 24 | return db("modules") 25 | .where({ 26 | module_name: options.moduleName, 27 | organization_id: options.organizationId, 28 | }) 29 | .delete(); 30 | }, 31 | 32 | async saveModuleForOrganization( 33 | db: Knex, 34 | options: { 35 | name: string; 36 | notify: notify_when; 37 | organizationId: number; 38 | } 39 | ): Promise { 40 | const e = await db("modules") 41 | .where({ 42 | organization_id: options.organizationId, 43 | module_name: options.name, 44 | }) 45 | .first(); 46 | 47 | if (e) { 48 | debug( 49 | "updating module_id %s for organization_id %s", 50 | e.id, 51 | options.organizationId 52 | ); 53 | return db("modules") 54 | .where({ 55 | organization_id: options.organizationId, 56 | module_name: options.name, 57 | }) 58 | .update({ 59 | notify_when: options.notify, 60 | }); 61 | } 62 | 63 | debug( 64 | "adding new module_id %s for organization_id %s", 65 | options.name, 66 | options.organizationId 67 | ); 68 | return db("modules").insert({ 69 | organization_id: options.organizationId, 70 | module_name: options.name, 71 | notify_when: options.notify, 72 | }); 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /src/server/models/user.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import { Knex } from "knex"; 3 | import debugLib from "debug"; 4 | import { InvalidCredentialsError, OrganizationExistsError } from "../errors.js"; 5 | 6 | const debug = debugLib("neith:server:models:user"); 7 | 8 | export const User = { 9 | createSecurePassword(plaintext: string) { 10 | const saltRounds = 10; 11 | return bcrypt.hash(plaintext, saltRounds); 12 | }, 13 | 14 | async signIn(db: Knex, email: string, plaintext: string) { 15 | const org = await db("organizations") 16 | .where({ organization_email: email }) 17 | .first(); 18 | 19 | if (!org) { 20 | debug("no organzation with email %s", email); 21 | throw new InvalidCredentialsError(); 22 | } 23 | 24 | console.log(plaintext, org); 25 | 26 | const hash = await bcrypt.compare(plaintext, org.organization_password); 27 | 28 | if (!hash) { 29 | throw new InvalidCredentialsError(); 30 | } 31 | 32 | return org.id; 33 | }, 34 | 35 | async signUp( 36 | db: Knex, 37 | organzationName: string, 38 | email: string, 39 | plaintext: string, 40 | timezone: string 41 | ) { 42 | const exists = await db("organizations") 43 | .where({ organization_email: email }) 44 | .first(); 45 | 46 | if (exists) { 47 | throw new OrganizationExistsError(email); 48 | } 49 | 50 | const hash = await bcrypt.hash(plaintext, 10); 51 | 52 | const [{ id }] = await db("organizations") 53 | .insert({ 54 | organization_name: organzationName, 55 | organization_email: email, 56 | organization_password: hash, 57 | }) 58 | .returning("id"); 59 | 60 | await db("jobs").insert({ 61 | job_description: `Default job for for organization ${id}`, 62 | job_last_run: null, 63 | job_name: "default_job", 64 | job_schedule: "weekly", 65 | job_starts_at: null, 66 | organization_id: id, 67 | timezone, 68 | }); 69 | 70 | debug("created organization with id %s", id); 71 | 72 | return id; 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /src/frontend/views/AccountPage.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 65 | 66 | 72 | -------------------------------------------------------------------------------- /cypress/tasks/server.ts: -------------------------------------------------------------------------------- 1 | import waitPort from "wait-port"; 2 | import url from "node:url"; 3 | import { PORT } from "../../src/shared/constants.js"; 4 | import { execa } from "execa"; 5 | import debugLib from "debug"; 6 | 7 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 8 | 9 | const debug = debugLib("neith:cypress:task:server"); 10 | 11 | /** 12 | * See if the server is open by checking processes listening on port 4444. 13 | */ 14 | export async function isOpen() { 15 | try { 16 | // permissions for good measure 17 | await execa("chmod", ["+x", "./getpid.sh"], { cwd: __dirname }); 18 | const { stdout } = await execa("./getpid.sh", { 19 | cwd: __dirname, 20 | shell: true, 21 | }); 22 | debug(`stdout for lsof %s`, stdout); 23 | if (!stdout) { 24 | return { 25 | open: false, 26 | }; 27 | } 28 | return { 29 | open: true, 30 | pids: stdout 31 | .trim() 32 | .split("\n") 33 | .map((x) => x.trim()), 34 | }; 35 | } catch (e) { 36 | debug("error", e); 37 | return { open: false }; 38 | } 39 | } 40 | 41 | export async function stopServer(): Promise { 42 | const check = await isOpen(); 43 | debug("stopping server %o", check); 44 | if (check.open && check.pids?.length) { 45 | const cmd: [string, string[]] = ["kill", ["-9", ...check.pids]]; 46 | debug(`killing %i with cmd: %o`, check.pids, cmd); 47 | try { 48 | await execa(...cmd); 49 | } catch (e: any) { 50 | debug("error killing server on %i: %s", e.message); 51 | // 52 | } 53 | } 54 | return null; 55 | } 56 | 57 | export async function startServer(): Promise { 58 | const check = await isOpen(); 59 | debug("check %o", check); 60 | 61 | if (check.open) { 62 | // Cypress task must resolve null to indicate success 63 | return null; 64 | } 65 | 66 | try { 67 | debug("starting server..."); 68 | execa(`npm`, ["run", "server"]); 69 | } catch (e: any) { 70 | debug("error starting server %s", e.message); 71 | } 72 | 73 | debug("waiting for port %s", PORT); 74 | const { open } = await waitPort({ 75 | host: "localhost", 76 | output: "silent", 77 | port: PORT, 78 | }); 79 | 80 | if (open) { 81 | debug("started server"); 82 | // Cypress task must resolve null to indicate success 83 | return null; 84 | } 85 | 86 | throw Error("Unknown error opening server"); 87 | } 88 | -------------------------------------------------------------------------------- /src/server/express.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import * as trpcExpress from "@trpc/server/adapters/express"; 3 | import path from "node:path"; 4 | import { PORT } from "../shared/constants.js"; 5 | import url from "node:url"; 6 | import knex from "knex"; 7 | import type { Knex } from "knex"; 8 | import cookieParser from "cookie-parser"; 9 | import bodyParser from "body-parser"; 10 | // @ts-expect-error 11 | import knexConfig from "../../knexfile.js"; 12 | import { sessionMiddleware } from "./session.js"; 13 | import { contextMiddleware } from "./context.js"; 14 | import { slack } from "./controllers/slack.js"; 15 | import { html } from "./controllers/html.js"; 16 | import { auth } from "./controllers/auth.js"; 17 | import { requiresAuth } from "./middleware/requiresAuth.js"; 18 | import { createContext, trpc } from "./controllers/trpc.js"; 19 | import { startScheduler } from "./services/jobs.js"; 20 | import debugLib from "debug"; 21 | 22 | const debug = debugLib("neith:server:express"); 23 | 24 | declare global { 25 | namespace Express { 26 | interface Request { 27 | db: Knex; 28 | session: { 29 | id: string; 30 | organizationId?: number; 31 | }; 32 | } 33 | } 34 | } 35 | 36 | export const knexClient = knex(knexConfig); 37 | 38 | // startScheduler(knexClient); 39 | 40 | const app = express(); 41 | 42 | app.use(cookieParser()); 43 | app.use(bodyParser.json()); 44 | app.use( 45 | bodyParser.urlencoded({ 46 | extended: true, 47 | }) 48 | ); 49 | app.use(contextMiddleware); 50 | app.use(sessionMiddleware); 51 | 52 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 53 | 54 | app.set("view engine", "pug"); 55 | app.set("views", path.join(__dirname, "views")); 56 | 57 | app.use(html); 58 | app.use(slack); 59 | app.use(auth); 60 | 61 | if (process.env.NODE_ENV === "production") { 62 | app.get<{ id: string }>("/assets/:id", async (req, res) => { 63 | debug("serving asset %s", req.params.id); 64 | const assetPath = path.join( 65 | __dirname, 66 | "..", 67 | "frontend", 68 | "dist", 69 | "assets", 70 | req.params.id 71 | ); 72 | res.sendFile(assetPath); 73 | }); 74 | } 75 | 76 | app.use( 77 | "/trpc", 78 | requiresAuth, 79 | trpcExpress.createExpressMiddleware({ 80 | router: trpc, 81 | createContext: createContext, 82 | }) 83 | ); 84 | 85 | app.listen(PORT, () => { 86 | console.log(`Listening on port ${PORT}`); 87 | }); 88 | -------------------------------------------------------------------------------- /src/frontend/views/DependenciesPage.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 82 | -------------------------------------------------------------------------------- /src/server/controllers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { User } from "../models/user.js"; 3 | import debugLib from "debug"; 4 | import { Session } from "../models/session.js"; 5 | 6 | const debug = debugLib("neith:server:controllers:auth"); 7 | 8 | export const auth = Router(); 9 | 10 | auth.get("/sign_up", (req, res) => { 11 | res.render("sign_up"); 12 | }); 13 | 14 | auth.get("/sign_in", (req, res) => { 15 | res.render("sign_in"); 16 | }); 17 | 18 | auth.post("/sign_out", async (req, res) => { 19 | if (!req.session.organizationId) { 20 | return res.redirect("/"); 21 | } 22 | 23 | debug("signing out id: %s", req.session.organizationId); 24 | 25 | res.cookie(Session.COOKIE_ID, "", { httpOnly: true }); 26 | res.redirect("/"); 27 | }); 28 | 29 | auth.post<{}, {}, { email: string; password: string }>( 30 | "/sign_in", 31 | async (req, res) => { 32 | const { password, ...rest } = req.body; 33 | debug("/sign_in: got req with body %o", rest); 34 | try { 35 | const organizationId = await User.signIn( 36 | req.db, 37 | req.body.email, 38 | req.body.password 39 | ); 40 | 41 | const sessionId = await Session.create(req.db, organizationId); 42 | 43 | res.cookie(...Session.makeSessionCookie(sessionId)); 44 | } catch (_err) { 45 | const e = _err as Error; 46 | debug("failed to sign in user %s", e.message); 47 | return res.render("sign_in", { 48 | flash: { 49 | type: "error", 50 | message: e.message, 51 | }, 52 | }); 53 | } 54 | 55 | res.redirect("/app"); 56 | } 57 | ); 58 | 59 | auth.post< 60 | {}, 61 | {}, 62 | { timezone: string; organization: string; email: string; password: string } 63 | >("/sign_up", async (req, res) => { 64 | const { password, ...rest } = req.body; 65 | debug("/sign_up: got req with body %o", rest); 66 | try { 67 | const organizationId = await User.signUp( 68 | req.db, 69 | req.body.organization, 70 | req.body.email, 71 | req.body.password, 72 | req.body.timezone 73 | ); 74 | 75 | const sessionId = await Session.create(req.db, organizationId); 76 | res.cookie(...Session.makeSessionCookie(sessionId)); 77 | 78 | debug("created session with id %s", sessionId); 79 | } catch (_err) { 80 | const e = _err as Error; 81 | debug("failed to sign up user %s", e.message); 82 | return res.render("sign_up", { 83 | flash: { 84 | type: "error", 85 | message: e.message, 86 | }, 87 | }); 88 | } 89 | 90 | res.redirect("/app"); 91 | }); 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Technologies 2 | 3 | The app uses: 4 | 5 | - [Postgres](https://www.postgresql.org/) for the database 6 | - [trpc](https://trpc.io/) for the API layer 7 | - [knex](https://knexjs.org/) for queries 8 | - [Vite](https://vitejs.dev/) for development and [Vitest](https://vitest.dev/) for testing 9 | - [Tailwind](https://tailwindcss.com/) for styling 10 | - [Cypress](https://www.cypress.io/) for End to End and Component Testing 11 | - [Vue.js](https://vuejs.org/) with the _Composition API_ and ` 51 | 52 | 89 | -------------------------------------------------------------------------------- /src/server/views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block content 4 | div(class='flex items-center flex-col bg-zinc-100 w-full') 5 | section(class='max-w-3xl p-4 md:p-0') 6 | h2(class='text-fuchsia-600 text-3xl md:text-6xl my-12 leading-tight') Stay one step ahead of the JavaScript ecosystem. 7 | 8 | p(class='text-3xl my-12 max-w-md leading-tight') Neith tracks your favorite npm packages and keeps you up to date with the latest releases. 9 | 10 | div(class="flex justify-center py-6 bg-white w-full") 11 | section(class='max-w-3xl p-4 md:p-0 md:text-xl') 12 | p(class='my-4') A JavaScript project has hundreds of dependencies... 13 | p(class='my-4') ... at least, a small one. Yours more likely has thousands. 14 | p(class='my-4') Cut through the cruft and stay up to date with the most important packages in your project, and ensure technical debt doesn't hold you back. 15 | 16 | div(class='flex items-center flex-col bg-zinc-100 w-full py-12') 17 | section(class='max-w-3xl md:flex justify-between w-full p-4 md:p-0') 18 | div(class='flex flex-col items-center') 19 | div 20 | ul 21 | each val in ['tailwind', 'webpack', 'react'] 22 | li(class='shadow-lg bg-white rounded-lg mb-4 px-4 py-2 w-36 text-center text-lg')= val 23 | div(class='flex items-center my-6 self-start md:self-auto') 24 | div(class="bg-fuchsia-600 rounded-full w-12 h-12 text-white flex items-center justify-center mr-2") 1 25 | | Choose Packages 26 | 27 | div(class='flex flex-col items-center justify-between') 28 | ul(class='rounded-lg bg-white border border-fuchsia-500 overflow-hidden w-48') 29 | each val in ['prerelease', 'minor', 'major'] 30 | li(class=`h-12 text-lg flex items-center justify-between px-4 ${val === 'major' ? 'bg-zinc-100' : ''}`) 31 | span= val 32 | if val === 'prerelease' 33 | div(class='before:content-["▾"] pb-1') 34 | div(class='flex items-center my-6 self-start md:self-auto') 35 | div(class="bg-fuchsia-600 rounded-full w-12 h-12 text-white flex items-center justify-center mr-2") 2 36 | | Choose Release Type 37 | 38 | div(class='flex flex-col items-center justify-between') 39 | div(class='rounded-lg bg-white border border-fuchsia-500 overflow-hidden w-60 p-2') 40 | div(class='flex justify-between items-center') 41 | div(class='flex') 42 | div Neith 43 | div(class='bg-zinc-200 text-zinc-800 rounded px-2 w-12 ml-2') APP 44 | div 45 | div(class='text-zinc-600 font-light text-xs') Monday 9:00am 46 | div(class='flex mt-4') 47 | div(class='border border-left border-2 border-blue-400 mr-4 ml-1') 48 | div 49 | div(class='mb-4') You've got updates! 50 | div Vite: 3.2.3 → 4.0.0-alpha.0. 51 | div React: 17.0.2 → 18.0.0. 52 | 53 | div(class='flex items-center my-6 self-start md:self-auto') 54 | div(class="bg-fuchsia-600 rounded-full w-12 h-12 text-white flex items-center justify-center mr-2") 3 55 | | Receive Updates 56 | 57 | div(class="flex justify-center py-6 bg-white w-full") 58 | section(class='max-w-3xl md:w-full md:text-xl p-4 md:p-0') 59 | p(class='my-4') Get started for #[a(href="/sign_up") free]... 60 | p(class='my-4 md:ml-32') ... or explore our #[a(href="/") business plan]... 61 | p(class='my-4 md:ml-96') ... or #[a(href="/") host your own]! -------------------------------------------------------------------------------- /test/migrations/utils.ts: -------------------------------------------------------------------------------- 1 | import knex from "knex"; 2 | import path from "node:path"; 3 | import { createKnex, execa, resetdb } from "../../scripts/utils.js"; 4 | import { it } from "vitest"; 5 | import fs from "node:fs/promises"; 6 | import url from "node:url"; 7 | import debugLib from "debug"; 8 | 9 | const debug = debugLib("neith:test:migration:utils"); 10 | 11 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 12 | 13 | export type KnexClientCallback = ( 14 | client: ReturnType 15 | ) => Promise; 16 | 17 | export interface MigrationTest { 18 | up: (callback: KnexClientCallback) => any; 19 | down: (callback: KnexClientCallback) => any; 20 | } 21 | 22 | async function migrationsToRun(migration: string) { 23 | const migrations = await fs.readdir( 24 | path.join(__dirname, "..", "..", "migrations") 25 | ); 26 | const toRun: string[] = []; 27 | for (let i = 0; i < migrations.length; i++) { 28 | toRun.push(migrations[i]); 29 | if (migrations[i].includes(migration)) { 30 | return toRun; 31 | } 32 | } 33 | return toRun; 34 | } 35 | 36 | export async function testMigration( 37 | migration: string, 38 | migrationTest: (fns: MigrationTest) => void 39 | ) { 40 | function up(cb: KnexClientCallback) { 41 | it(`${migration} - up`, async () => { 42 | // destroy existing db and create new one 43 | const dbname = await resetdb(); 44 | 45 | const migrations = await migrationsToRun(migration); 46 | debug("migrations to run %o", migrations); 47 | 48 | // migration 49 | for (const toRun of migrations) { 50 | debug("running %s", toRun); 51 | await execa(`npm run db:test:migrate:up ${toRun}`, { 52 | POSTGRES_DB: dbname, 53 | }); 54 | } 55 | 56 | const client = createKnex(dbname); 57 | 58 | try { 59 | await cb(client); 60 | } catch (e) { 61 | throw e; 62 | } finally { 63 | // cleanup - close connection 64 | await client.destroy(); 65 | 66 | try { 67 | debug(`Dropping ${dbname}`); 68 | await execa(`dropdb ${dbname}`); 69 | } catch (e) { 70 | console.error(`Failed to dropdb ${dbname}`, e); 71 | } 72 | } 73 | }); 74 | } 75 | 76 | function down(cb: KnexClientCallback) { 77 | it(`${migration} - down`, async () => { 78 | // destroy existing db and create new one 79 | const dbname = await resetdb(); 80 | 81 | const migrations = await migrationsToRun(migration); 82 | 83 | // migration 84 | for (const toRun of migrations) { 85 | debug("running %s", toRun); 86 | await execa(`npm run db:test:migrate:up ${toRun}`, { 87 | POSTGRES_DB: dbname, 88 | }); 89 | } 90 | 91 | await execa(`npm run db:test:migrate:down`, { 92 | POSTGRES_DB: dbname, 93 | }); 94 | 95 | // run test 96 | const client = createKnex(dbname); 97 | try { 98 | await cb(client); 99 | } catch (e) { 100 | throw e; 101 | } finally { 102 | // cleanup - close connection 103 | await client.destroy(); 104 | 105 | try { 106 | debug(`Dropping ${dbname}`); 107 | await execa(`dropdb ${dbname}`); 108 | } catch (e) { 109 | console.error(`Failed to dropdb ${dbname}`, e); 110 | } 111 | } 112 | }); 113 | } 114 | 115 | migrationTest({ up, down }); 116 | } 117 | -------------------------------------------------------------------------------- /src/frontend/views/NotificationsPage/SlackCard.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 63 | -------------------------------------------------------------------------------- /src/frontend/views/NotificationsPage.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 112 | -------------------------------------------------------------------------------- /src/notify.ts: -------------------------------------------------------------------------------- 1 | import type { ModuleInfo } from "./types"; 2 | import { notify_when, schedule } from "../dbschema.js"; 3 | import { DateTime } from "luxon"; 4 | import semver from "semver"; 5 | 6 | export interface NotifyModule { 7 | npmInfo: Pick; 8 | notifyWhen: notify_when; 9 | } 10 | 11 | export interface NotifyPayload { 12 | modules: NotifyModule[]; 13 | schedule: schedule; 14 | now: string; 15 | } 16 | 17 | interface ModuleVersion { 18 | published: string; 19 | version: string; 20 | } 21 | 22 | interface NotifyModuleResult { 23 | name: string; 24 | previousVersion: ModuleVersion; 25 | currentVersion: ModuleVersion; 26 | } 27 | 28 | function getPrevDateTime(schedule: schedule, now: string): string { 29 | const prev = DateTime.fromISO(now, { zone: "utc" }); 30 | 31 | if (!prev.isValid) { 32 | throw Error(`Could not parse datetime: ${prev.invalidReason}`); 33 | } 34 | 35 | if (schedule === "daily") { 36 | return prev.minus({ days: 1 }).toISO(); 37 | } 38 | 39 | if (schedule === "weekly") { 40 | return prev.minus({ weeks: 1 }).toISO(); 41 | } 42 | 43 | throw Error(`Expected schedule to be daily or weekly, got ${schedule}`); 44 | } 45 | 46 | export function shouldNotify( 47 | notifyWhen: notify_when, 48 | previousMajor: string, 49 | currentMajor: string 50 | ): boolean { 51 | const diff = semver.diff(previousMajor, currentMajor); 52 | 53 | if (!diff) { 54 | return false; 55 | } 56 | 57 | switch (notifyWhen) { 58 | case "major": { 59 | return diff === "major"; 60 | } 61 | 62 | case "minor": { 63 | return ["major", "minor"].includes(diff); 64 | } 65 | 66 | case "prerelease": { 67 | const diffs: semver.ReleaseType[] = ["premajor", "prerelease"]; 68 | return diffs.includes(diff); 69 | } 70 | 71 | default: { 72 | throw Error(`${notifyWhen} is not implemented!`); 73 | } 74 | } 75 | } 76 | 77 | /** 78 | * The notify job fetches the latest version for each module the organization is subscribed to. 79 | * We see if any new version was released, and if it matches the version change the org is subscribed to. 80 | * If it does, we want to notify the user via their preferred method. 81 | */ 82 | export function notify(payload: NotifyPayload): NotifyModuleResult[] { 83 | let modules: NotifyModuleResult[] = []; 84 | 85 | for (const mod of payload.modules) { 86 | const versions = Object.entries(mod.npmInfo.time).map( 87 | (x) => { 88 | return { 89 | version: x[0], 90 | published: x[1], 91 | }; 92 | } 93 | ); 94 | 95 | const notifyWindow = getPrevDateTime(payload.schedule, payload.now); 96 | const previousMajor = getLatest(versions, notifyWindow); 97 | const currentMajor = getLatest(versions, payload.now); 98 | 99 | if ( 100 | shouldNotify(mod.notifyWhen, previousMajor.version, currentMajor.version) 101 | ) { 102 | modules.push({ 103 | name: mod.npmInfo.name, 104 | previousVersion: previousMajor, 105 | currentVersion: currentMajor, 106 | }); 107 | } 108 | } 109 | 110 | return modules; 111 | } 112 | export interface VersionHistory { 113 | version: string; 114 | published: string; 115 | } 116 | 117 | export type Comparator = ( 118 | current: VersionHistory, 119 | candidate: VersionHistory 120 | ) => boolean; 121 | 122 | export function isoToUtc(iso: string) { 123 | const dt = DateTime.fromISO(iso, { zone: "utc" }); 124 | if (!dt.isValid) { 125 | throw Error("Invalid datetime!"); 126 | } 127 | return dt; 128 | } 129 | 130 | function makeComparator(cutoff: string): Comparator { 131 | return (v1, v2) => { 132 | const dt1 = isoToUtc(v1.published); 133 | const dt2 = isoToUtc(v2.published); 134 | 135 | return dt2 > dt1 && dt2 < isoToUtc(cutoff); 136 | }; 137 | } 138 | 139 | export function getLatestPrerelease( 140 | versions: VersionHistory[], 141 | cutoff: string 142 | ): VersionHistory { 143 | const comparator = makeComparator(cutoff); 144 | 145 | let best: VersionHistory = versions[0]; 146 | for (const version of versions) { 147 | const isPrerelease = Boolean(semver.prerelease(version.version)); 148 | 149 | if (comparator(best, version) && isPrerelease) { 150 | best = version; 151 | } 152 | } 153 | 154 | return best; 155 | } 156 | 157 | export function getLatest( 158 | versions: VersionHistory[], 159 | cutoff: string 160 | ): VersionHistory { 161 | const comparator = makeComparator(cutoff); 162 | 163 | let best: VersionHistory = versions[0]; 164 | for (const version of versions) { 165 | if ( 166 | comparator(best, version) && 167 | semver.compare(version.version, best.version) === 1 168 | ) { 169 | best = version; 170 | } 171 | } 172 | 173 | return best; 174 | } 175 | -------------------------------------------------------------------------------- /src/server/models/organization.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from "knex"; 2 | import dedent from "dedent"; 3 | import { Emails, Jobs, Organizations, schedule } from "../../../dbschema.js"; 4 | import debugLib from "debug"; 5 | import { notify, NotifyPayload } from "../../notify.js"; 6 | 7 | const debug = debugLib("neith:server:models:organization"); 8 | 9 | interface NotificationSettings { 10 | frequency: schedule; 11 | } 12 | 13 | export const Organization = { 14 | async getJobWithOrg(db: Knex, options: { organizationId: number }) { 15 | const res = await db("organizations") 16 | .join("jobs", "organizations.id", "=", "jobs.organization_id") 17 | .where("jobs.organization_id", options.organizationId) 18 | .first(); 19 | return res as Organizations & Jobs; 20 | }, 21 | 22 | async getAllWithJobs(db: Knex) { 23 | const res = (await db("organizations").join( 24 | "jobs", 25 | "organizations.id", 26 | "=", 27 | "jobs.organization_id" 28 | )) as Array; 29 | 30 | return res; 31 | }, 32 | 33 | notificationEmailContent(notifyPayload: NotifyPayload) { 34 | const data = notify(notifyPayload); 35 | const moduleInfo = data.map((info) => { 36 | return `${info.name}: ${info.previousVersion.version} -> ${info.currentVersion.version}\n`; 37 | }); 38 | 39 | const msg = moduleInfo.length 40 | ? `the following packages received updates: \n\n${moduleInfo}` 41 | : "none of the packages you are subscribed to have a new release.\n"; 42 | 43 | return dedent` 44 | Hi, 45 | 46 | This your ${ 47 | notifyPayload.schedule 48 | } update for your packages from neith.dev. 49 | 50 | In the last ${notifyPayload.schedule === "daily" ? "day" : "week"}, ${msg} 51 | - the neith.dev team. 52 | `; 53 | }, 54 | 55 | async getOrganizationById( 56 | db: Knex, 57 | options: { organizationId: number } 58 | ): Promise { 59 | const org = await db("organizations") 60 | .where({ 61 | id: options.organizationId, 62 | }) 63 | .first(); 64 | 65 | if (!org) { 66 | debug(`Did not find org for organization_id: %s`, options.organizationId); 67 | throw new Error( 68 | `Did not find org for organization_id: ${options.organizationId}` 69 | ); 70 | } 71 | 72 | return org; 73 | }, 74 | 75 | async updateOrganization( 76 | db: Knex, 77 | options: { organizationId: number; props: Partial } 78 | ): Promise { 79 | const { id, ...props } = options.props; 80 | try { 81 | await db("organizations") 82 | .where({ 83 | id: options.organizationId, 84 | }) 85 | .update(props); 86 | } catch (e) { 87 | debug(`Could not update organzation with invalid props %o`, options); 88 | } 89 | }, 90 | 91 | async getJob(db: Knex, options: { organizationId: number }): Promise { 92 | const job = await db("jobs") 93 | .where({ 94 | organization_id: options.organizationId, 95 | }) 96 | .first(); 97 | 98 | if (!job) { 99 | debug(`Did not find job for organization_id: %s`, options.organizationId); 100 | throw new Error( 101 | `Did not find job for organization_id: ${options.organizationId}` 102 | ); 103 | } 104 | 105 | return job; 106 | }, 107 | 108 | async deleteEmail( 109 | db: Knex, 110 | options: { organizationId: number; id: number } 111 | ): Promise { 112 | debug( 113 | "deleting email id: %s for organization_id: %s", 114 | options.id, 115 | options.organizationId 116 | ); 117 | return db("emails") 118 | .where({ 119 | id: options.id, 120 | organization_id: options.organizationId, 121 | }) 122 | .delete(); 123 | }, 124 | 125 | async addEmail( 126 | db: Knex, 127 | options: { organizationId: number; email: string } 128 | ): Promise { 129 | debug( 130 | "inserting email: %s for organization_id: %s", 131 | options.email, 132 | options.organizationId 133 | ); 134 | return db("emails").insert({ 135 | organization_id: options.organizationId, 136 | email: options.email, 137 | }); 138 | }, 139 | 140 | async getEmails( 141 | db: Knex, 142 | options: { organizationId: number } 143 | ): Promise { 144 | const emails = await db("emails").where( 145 | "organization_id", 146 | options.organizationId 147 | ); 148 | 149 | return emails; 150 | }, 151 | 152 | async getNotificationSettings( 153 | db: Knex, 154 | options: { organizationId: number } 155 | ): Promise { 156 | const job = await this.getJob(db, { 157 | organizationId: options.organizationId, 158 | }); 159 | 160 | return { 161 | frequency: job.job_schedule, 162 | }; 163 | }, 164 | }; 165 | -------------------------------------------------------------------------------- /src/server/controllers/trpc.ts: -------------------------------------------------------------------------------- 1 | import { inferAsyncReturnType, initTRPC } from "@trpc/server"; 2 | import type { CreateExpressContextOptions } from "@trpc/server/adapters/express"; 3 | import assert from "node:assert"; 4 | import debugLib from "debug"; 5 | import { notify_when, Organizations, schedule } from "../../../dbschema.js"; 6 | import { Job } from "../models/job.js"; 7 | import { Organization } from "../models/organization.js"; 8 | import { Package } from "../models/package.js"; 9 | import { Registry } from "../models/registry.js"; 10 | import { rescheduleJob } from "../services/jobs.js"; 11 | 12 | const debug = debugLib("neith:server:controllers:trpc"); 13 | 14 | export const createContext = ({ req, res }: CreateExpressContextOptions) => ({ 15 | req, 16 | res, 17 | }); 18 | 19 | const t = initTRPC 20 | .context>() 21 | .create(); 22 | 23 | export const trpc = t.router({ 24 | getUser: t.procedure.query((req) => { 25 | return { id: req.input, name: "Bilbo" }; 26 | }), 27 | 28 | getOrganizationModules: t.procedure.query(async (req) => { 29 | const pkgs = await Package.getModulesForOrganization(req.ctx.req.db, { 30 | organizationId: req.ctx.req.session.organizationId!, 31 | }); 32 | 33 | return Promise.all( 34 | pkgs.map(async (pkg) => ({ 35 | ...(await Registry.fetchPackage(pkg.module_name)), 36 | notifyWhen: pkg.notify_when, 37 | })) 38 | ); 39 | }), 40 | 41 | getDependencies: t.procedure 42 | .input((pkgName) => { 43 | return pkgName as string; 44 | }) 45 | .query((req) => { 46 | return Registry.fetchPackage(req.input); 47 | }), 48 | 49 | getOrganizationEmails: t.procedure.query(async (req) => { 50 | assert( 51 | req.ctx.req.session.organizationId, 52 | `organizationId should be defined` 53 | ); 54 | const emails = await Organization.getEmails(req.ctx.req.db, { 55 | organizationId: req.ctx.req.session.organizationId, 56 | }); 57 | return emails; 58 | }), 59 | 60 | deleteEmail: t.procedure 61 | .input((id) => id as number) 62 | .mutation(async (req) => { 63 | assert( 64 | req.ctx.req.session.organizationId, 65 | `organizationId should be defined` 66 | ); 67 | return Organization.deleteEmail(req.ctx.req.db, { 68 | organizationId: req.ctx.req.session.organizationId, 69 | id: req.input, 70 | }); 71 | }), 72 | 73 | addEmail: t.procedure 74 | .input((value) => { 75 | return value as string; 76 | }) 77 | .mutation(async (req) => { 78 | assert( 79 | req.ctx.req.session.organizationId, 80 | `organizationId should be defined` 81 | ); 82 | return Organization.addEmail(req.ctx.req.db, { 83 | organizationId: req.ctx.req.session.organizationId, 84 | email: req.input, 85 | }); 86 | }), 87 | 88 | getNotificationSettings: t.procedure.query(async (req) => { 89 | assert( 90 | req.ctx.req.session.organizationId, 91 | `organizationId should be defined` 92 | ); 93 | const model = await Organization.getNotificationSettings(req.ctx.req.db, { 94 | organizationId: req.ctx.req.session.organizationId, 95 | }); 96 | return model; 97 | }), 98 | 99 | savePackage: t.procedure 100 | .input((pkg) => { 101 | return pkg as { name: string; frequency: notify_when }; 102 | }) 103 | .mutation((req) => { 104 | assert( 105 | req.ctx.req.session.organizationId, 106 | `organizationId should be defined` 107 | ); 108 | return Package.saveModuleForOrganization(req.ctx.req.db, { 109 | name: req.input.name, 110 | notify: req.input.frequency, 111 | organizationId: req.ctx.req.session.organizationId, 112 | }); 113 | }), 114 | 115 | deleteDependency: t.procedure 116 | .input((obj) => obj as string) 117 | .mutation(async (req) => { 118 | return Package.delete(req.ctx.req.db, { 119 | moduleName: req.input, 120 | organizationId: req.ctx.req.session.organizationId!, 121 | }); 122 | }), 123 | 124 | saveFrequency: t.procedure 125 | .input((schedule) => { 126 | return schedule as schedule; 127 | }) 128 | .mutation(async (req) => { 129 | debug("updating frequency to %s", req.input); 130 | await Job.updateJobScheduleForOrganization(req.ctx.req.db, { 131 | organizationId: req.ctx.req.session.organizationId!, 132 | jobSchedule: req.input, 133 | }); 134 | 135 | return rescheduleJob(req.ctx.req.db, req.ctx.req.session.organizationId!); 136 | }), 137 | 138 | getOrganization: t.procedure.query(async (req) => { 139 | return Organization.getOrganizationById(req.ctx.req.db, { 140 | organizationId: req.ctx.req.session.organizationId!, 141 | }); 142 | }), 143 | 144 | updateOrganization: t.procedure 145 | .input((input) => input as Partial) 146 | .mutation(async (req) => { 147 | return Organization.updateOrganization(req.ctx.req.db, { 148 | organizationId: req.ctx.req.session.organizationId!, 149 | props: req.input, 150 | }); 151 | }), 152 | }); 153 | 154 | // export type definition of API 155 | export type TRPC_Router = typeof trpc; 156 | -------------------------------------------------------------------------------- /test/server/services/jobs.spec.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import { describe, expect, it } from "vitest"; 3 | import { 4 | millisUntilNextDesginatedHour, 5 | millisUntilNextMondayAtHours, 6 | Scheduler, 7 | } from "../../../src/server/services/jobs.js"; 8 | import { toHuman } from "../../../src/server/utils.js"; 9 | 10 | describe("millisUntilNextDesginatedHour", () => { 11 | it("gets 9AM on the same day", () => { 12 | // 8th of Jan 2023 is a Sunday. 13 | // This function currently is hardcoded to get the next 9AM. 14 | // In this case, it is in 1 hour time. 15 | const now = DateTime.fromObject( 16 | { 17 | year: 2023, 18 | month: 1, 19 | day: 8, 20 | hour: 8, 21 | minute: 0, 22 | second: 0, 23 | millisecond: 0, 24 | }, 25 | { zone: "utc" } 26 | ); 27 | 28 | const actual = millisUntilNextDesginatedHour(now, "utc"); 29 | 30 | // 3600000ms => 1 hour 31 | expect(actual).toBe(3600000); 32 | expect(toHuman(actual)).toMatchInlineSnapshot(` 33 | { 34 | "hours": 1, 35 | "mins": 0, 36 | "secs": 0, 37 | } 38 | `); 39 | }); 40 | 41 | it("gets 9AM on next day", () => { 42 | // 8th of Jan 2023 is a Sunday. 43 | // This function currently is hardcoded to get the next 9AM. 44 | // In this case, it is in 11 hours time. 45 | const now = DateTime.fromObject( 46 | { 47 | year: 2023, 48 | month: 1, 49 | day: 8, 50 | hour: 22, 51 | minute: 0, 52 | second: 0, 53 | millisecond: 0, 54 | }, 55 | { zone: "utc" } 56 | ); 57 | 58 | const actual = millisUntilNextDesginatedHour(now, "utc"); 59 | 60 | // 36000000ms => 11 hours 61 | expect(actual).toBe(39600000); 62 | expect(toHuman(actual)).toMatchInlineSnapshot(` 63 | { 64 | "hours": 11, 65 | "mins": 0, 66 | "secs": 0, 67 | } 68 | `); 69 | }); 70 | 71 | it("gets 9AM on next day if the current time is 9:00.XX", () => { 72 | // 9th of Jan 2023 is a Monday. 73 | const now = DateTime.fromObject( 74 | { 75 | year: 2023, 76 | month: 1, 77 | day: 9, 78 | hour: 9, 79 | minute: 4, 80 | second: 1, 81 | millisecond: 0, 82 | }, 83 | { zone: "utc" } 84 | ); 85 | 86 | const actual = millisUntilNextDesginatedHour(now, "utc"); 87 | 88 | expect(toHuman(actual)).toMatchInlineSnapshot(` 89 | { 90 | "hours": 23, 91 | "mins": 55, 92 | "secs": 59, 93 | } 94 | `); 95 | // 86159000ms => 23h 56m 59s 96 | expect(actual).toBe(86159000); 97 | }); 98 | }); 99 | 100 | describe("millisUntilNextMondayAtHours", () => { 101 | it("works when now is in previous week", () => { 102 | // 8th of Jan 2023 is a Sunday. 103 | const now = DateTime.fromObject( 104 | { 105 | year: 2023, 106 | month: 1, 107 | day: 8, 108 | hour: 9, 109 | minute: 0, 110 | second: 0, 111 | millisecond: 0, 112 | }, 113 | { zone: "utc" } 114 | ); 115 | 116 | const actual = millisUntilNextMondayAtHours(now, "utc"); 117 | 118 | // 24 hours 119 | expect(toHuman(actual)).toMatchInlineSnapshot(` 120 | { 121 | "hours": 24, 122 | "mins": 0, 123 | "secs": 0, 124 | } 125 | `); 126 | expect(actual).toBe(86400000); 127 | }); 128 | 129 | it("works when now is in previous week", () => { 130 | // 8th of Jan 2023 is a Sunday. 131 | const now = DateTime.fromObject( 132 | { 133 | year: 2023, 134 | month: 1, 135 | day: 8, 136 | hour: 9, 137 | minute: 0, 138 | second: 0, 139 | millisecond: 0, 140 | }, 141 | { zone: "America/New_York" } 142 | ); 143 | 144 | const actual = millisUntilNextMondayAtHours(now, "America/New_York"); 145 | 146 | // 24 hours 147 | expect(actual).toBe(86400000); 148 | expect(toHuman(actual)).toMatchInlineSnapshot(` 149 | { 150 | "hours": 24, 151 | "mins": 0, 152 | "secs": 0, 153 | } 154 | `); 155 | }); 156 | 157 | it("gets correct value when comparing utc and specific timezone", () => { 158 | // 8th of Jan 2023 is a Sunday. 159 | const now = DateTime.fromObject( 160 | { 161 | year: 2023, 162 | month: 1, 163 | day: 8, 164 | hour: 9, 165 | minute: 0, 166 | second: 0, 167 | millisecond: 0, 168 | }, 169 | { zone: "utc" } 170 | ); 171 | 172 | const actual = millisUntilNextMondayAtHours(now, "America/New_York"); 173 | 174 | // 29h => 104400000ms 175 | expect(toHuman(actual)).toMatchInlineSnapshot(` 176 | { 177 | "hours": 29, 178 | "mins": 0, 179 | "secs": 0, 180 | } 181 | `); 182 | expect(actual).toBe(104400000); 183 | }); 184 | }); 185 | 186 | describe("Scheduler", () => { 187 | it("clears and re-schdeduls job", () => { 188 | const scheduler = new Scheduler(); 189 | 190 | return new Promise((done) => { 191 | let i = 0; 192 | const inc = () => { 193 | i++; 194 | return Promise.resolve(true); 195 | }; 196 | 197 | scheduler.schedule({ 198 | calcMillisUntilExecution: () => 1000, 199 | organizationId: 1, 200 | callback: inc, 201 | }); 202 | 203 | global.setTimeout(() => { 204 | scheduler.clearSchedule(1); 205 | scheduler.schedule({ 206 | calcMillisUntilExecution: () => 1000, 207 | organizationId: 1, 208 | callback: inc, 209 | }); 210 | }, 500); 211 | 212 | global.setTimeout(() => { 213 | expect(i).toBe(0); 214 | }, 1200); 215 | 216 | global.setTimeout(() => { 217 | expect(i).toBe(1); 218 | expect(Array.from(scheduler.jobs.keys()).length).toBe(0); 219 | expect(scheduler.jobs).toMatchInlineSnapshot("Map {}"); 220 | done(); 221 | }, 1700); 222 | }); 223 | }); 224 | 225 | it("re-queues job for execution", () => { 226 | const scheduler = new Scheduler(); 227 | return new Promise((done) => { 228 | let i = 0; 229 | const inc = () => { 230 | i++; 231 | return Promise.resolve(i === 1 ? true : false); 232 | }; 233 | 234 | scheduler.schedule({ 235 | calcMillisUntilExecution: () => 500, 236 | organizationId: 1, 237 | callback: inc, 238 | recurring: { 239 | calculateNextExecutionMillis: () => 500, 240 | }, 241 | }); 242 | 243 | global.setTimeout(() => { 244 | expect(i).toBe(1); 245 | }, 700); 246 | 247 | global.setTimeout(() => { 248 | expect(i).toBe(2); 249 | expect(Array.from(scheduler.jobs.keys()).length).toBe(0); 250 | expect(scheduler.jobs).toMatchInlineSnapshot("Map {}"); 251 | done(); 252 | }, 1200); 253 | }); 254 | }); 255 | }); 256 | -------------------------------------------------------------------------------- /src/server/services/jobs.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import { Jobs, Organizations, schedule } from "../../../dbschema.js"; 3 | import type { NotifyModule, NotifyPayload } from "../../notify.js"; 4 | import debugLib from "debug"; 5 | import { Mailer } from "./mailer.js"; 6 | import { Package } from "../models/package.js"; 7 | import type { Knex } from "knex"; 8 | import { Organization } from "../models/organization.js"; 9 | import { Registry } from "../models/registry.js"; 10 | import { toHuman } from "../utils.js"; 11 | 12 | const debug = debugLib("neith:server:services:jobs"); 13 | 14 | const DESIGNATED_HOUR = 9; 15 | 16 | /** 17 | * Get a list of all jobs and the next scheduled date. 18 | */ 19 | export interface Job { 20 | name: string; 21 | organizationId: number; 22 | schedule: schedule; 23 | timezone: string; 24 | callback: () => Promise; 25 | doneCallback?: () => void; 26 | } 27 | 28 | export function millisUntilNextDesginatedHour( 29 | now: DateTime, 30 | timezone: string, 31 | hour = DESIGNATED_HOUR 32 | ) { 33 | let d = now.setZone(timezone); 34 | 35 | if (d.hour === hour) { 36 | d = d.plus({ hour: 1 }); 37 | } 38 | 39 | while (d.hour !== hour) { 40 | d = d.plus({ hour: 1 }); 41 | } 42 | 43 | d = d.set({ 44 | minute: 0, 45 | second: 0, 46 | millisecond: 0, 47 | }); 48 | 49 | return d.diff(now, "milliseconds").toMillis(); 50 | } 51 | export function millisUntilNextMondayAtHours( 52 | now: DateTime, 53 | timezone: string, 54 | hours = DESIGNATED_HOUR 55 | ) { 56 | const monday = 1; 57 | let d = now.setZone(timezone); 58 | 59 | while (d.weekday !== monday) { 60 | d = d.plus({ day: 1 }); 61 | } 62 | 63 | d = d.set({ 64 | hour: hours, 65 | minute: 0, 66 | second: 0, 67 | millisecond: 0, 68 | }); 69 | 70 | return d.diff(now, "milliseconds").toMillis(); 71 | } 72 | 73 | interface JobToRun { 74 | /** 75 | * Number of milliseconds until this job should execute. 76 | */ 77 | calcMillisUntilExecution: () => number; 78 | 79 | /** 80 | * Organization that job belongs to. 81 | */ 82 | organizationId: number; 83 | 84 | /** 85 | * Function to execute when running job. 86 | * 87 | * If the callback resolves `false`, the job will not re-run, 88 | * even if `recurring` is provided. 89 | */ 90 | callback: () => Promise; 91 | 92 | /** 93 | * Optional callback, executed when the job has finished running. 94 | */ 95 | doneCallback?: () => void; 96 | 97 | /** 98 | * A job can be recurring. If so, a function that calculates 99 | * when it should next run should be provided. 100 | * The function should return the number of milliseconds until 101 | * the next execution. 102 | */ 103 | recurring?: { 104 | calculateNextExecutionMillis: () => number; 105 | }; 106 | } 107 | 108 | export class Scheduler { 109 | #jobs = new Map(); 110 | 111 | get jobs() { 112 | return this.#jobs; 113 | } 114 | 115 | clearSchedule(organizationId: number) { 116 | const id = this.#jobs.get(organizationId); 117 | if (!id) { 118 | debug( 119 | "tried clearing job with organization_id %s but one does not exist.", 120 | organizationId 121 | ); 122 | return; 123 | } 124 | 125 | global.clearTimeout(id); 126 | this.#jobs.delete(organizationId); 127 | } 128 | 129 | schedule(jobToRun: JobToRun) { 130 | debug( 131 | "time is now %s. Scheduling job to run in %s ms. That is in %o", 132 | DateTime.now().toISO(), 133 | jobToRun.calcMillisUntilExecution(), 134 | toHuman(jobToRun.calcMillisUntilExecution()) 135 | ); 136 | 137 | const timeoutID = global.setTimeout(async () => { 138 | const repeat = await jobToRun.callback(); 139 | jobToRun.doneCallback?.(); 140 | 141 | this.#jobs.delete(jobToRun.organizationId); 142 | 143 | if (repeat !== false && jobToRun.recurring) { 144 | this.schedule({ 145 | ...jobToRun, 146 | calcMillisUntilExecution: 147 | jobToRun.recurring.calculateNextExecutionMillis, 148 | }); 149 | } 150 | }, jobToRun.calcMillisUntilExecution()); 151 | 152 | this.#jobs.set(jobToRun.organizationId, timeoutID); 153 | } 154 | } 155 | 156 | const scheduler = new Scheduler(); 157 | 158 | function calculateNextExecution(timezone: string, schedule: schedule): number { 159 | if (schedule === "daily") { 160 | return millisUntilNextDesginatedHour( 161 | DateTime.now(), 162 | timezone, 163 | DESIGNATED_HOUR 164 | ); 165 | } 166 | 167 | // must be weekly, the default 168 | return millisUntilNextMondayAtHours( 169 | DateTime.now(), 170 | timezone, 171 | DESIGNATED_HOUR 172 | ); 173 | } 174 | 175 | export function scheduleJob( 176 | job: Organizations & Jobs, 177 | task: () => Promise 178 | ) { 179 | const jobToRun: JobToRun = { 180 | calcMillisUntilExecution: () => { 181 | return calculateNextExecution(job.timezone, job.job_schedule); 182 | }, 183 | 184 | organizationId: job.organization_id, 185 | 186 | callback: task, 187 | 188 | recurring: { 189 | calculateNextExecutionMillis: () => { 190 | return calculateNextExecution(job.timezone, job.job_schedule); 191 | }, 192 | }, 193 | }; 194 | 195 | scheduler.schedule(jobToRun); 196 | 197 | return scheduler; 198 | } 199 | 200 | export async function fetchOrganizationModules( 201 | db: Knex, 202 | options: { organizationId: number } 203 | ): Promise { 204 | const pkgs = await Package.getModulesForOrganization(db, options); 205 | 206 | const npmInfo = await Promise.all( 207 | pkgs.map(async (x) => { 208 | const info = await Registry.fetchFromRegistry(x.module_name); 209 | delete info.time["created"]; 210 | delete info.time["modified"]; 211 | return { 212 | npmInfo: info, 213 | name: x.module_name, 214 | notifyWhen: x.notify_when, 215 | }; 216 | }) 217 | ); 218 | 219 | return npmInfo; 220 | } 221 | 222 | async function sendMail(db: Knex, job: Jobs & Organizations) { 223 | debug("Sending mail for organization_id: %s", job.organization_id); 224 | 225 | const payload: NotifyPayload = { 226 | modules: await fetchOrganizationModules(db, { 227 | organizationId: job.organization_id, 228 | }), 229 | schedule: job.job_schedule, 230 | now: DateTime.now().toISO(), 231 | }; 232 | 233 | const emailData = Organization.notificationEmailContent(payload); 234 | debug("Sending email: %s", emailData); 235 | await Mailer.sendEmail(emailData); 236 | } 237 | 238 | export async function rescheduleJob(db: Knex, organizationId: number) { 239 | const job = await Organization.getJobWithOrg(db, { organizationId }); 240 | debug("rescheduling job %o", job); 241 | scheduler.clearSchedule(job.organization_id); 242 | 243 | scheduleJob(job, () => sendMail(db, job)); 244 | } 245 | 246 | export async function startScheduler(db: Knex) { 247 | debug("Starting scheduler..."); 248 | 249 | const jobs = await Organization.getAllWithJobs(db); 250 | 251 | debug("starting jobs %s", JSON.stringify(jobs, null, 2)); 252 | 253 | for (const job of jobs) { 254 | scheduleJob(job, () => sendMail(db, job)); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /dbschema.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | 4 | /** 5 | * AUTO-GENERATED FILE - DO NOT EDIT! 6 | * 7 | * This file was automatically generated by pg-to-ts v.4.1.0 8 | * $ pg-to-ts generate -c postgresql://username:password@localhost/notifier_test -t emails -t jobs -t knex_migrations -t knex_migrations_lock -t modules -t organizations -t sessions -s public 9 | * 10 | */ 11 | 12 | export type Json = unknown; 13 | export type notify_when = "major" | "minor" | "prerelease"; 14 | export type schedule = "daily" | "weekly"; 15 | 16 | // Table emails 17 | export interface Emails { 18 | id: number; 19 | email: string; 20 | organization_id: number | null; 21 | } 22 | export interface EmailsInput { 23 | id?: number; 24 | email: string; 25 | organization_id?: number | null; 26 | } 27 | const emails = { 28 | tableName: "emails", 29 | columns: ["id", "email", "organization_id"], 30 | requiredForInsert: ["email"], 31 | primaryKey: "id", 32 | foreignKeys: { 33 | organization_id: { 34 | table: "organizations", 35 | column: "id", 36 | $type: null as unknown as Organizations, 37 | }, 38 | }, 39 | $type: null as unknown as Emails, 40 | $input: null as unknown as EmailsInput, 41 | } as const; 42 | 43 | // Table jobs 44 | export interface Jobs { 45 | id: number; 46 | organization_id: number; 47 | job_name: string | null; 48 | job_description: string | null; 49 | job_starts_at: Date | null; 50 | job_last_run: Date | null; 51 | job_schedule: schedule; 52 | timezone: string; 53 | } 54 | export interface JobsInput { 55 | id?: number; 56 | organization_id: number; 57 | job_name?: string | null; 58 | job_description?: string | null; 59 | job_starts_at?: Date | null; 60 | job_last_run?: Date | null; 61 | job_schedule: schedule; 62 | timezone: string; 63 | } 64 | const jobs = { 65 | tableName: "jobs", 66 | columns: [ 67 | "id", 68 | "organization_id", 69 | "job_name", 70 | "job_description", 71 | "job_starts_at", 72 | "job_last_run", 73 | "job_schedule", 74 | "timezone", 75 | ], 76 | requiredForInsert: ["organization_id", "job_schedule", "timezone"], 77 | primaryKey: "id", 78 | foreignKeys: { 79 | organization_id: { 80 | table: "organizations", 81 | column: "id", 82 | $type: null as unknown as Organizations, 83 | }, 84 | }, 85 | $type: null as unknown as Jobs, 86 | $input: null as unknown as JobsInput, 87 | } as const; 88 | 89 | // Table knex_migrations 90 | export interface KnexMigrations { 91 | id: number; 92 | name: string | null; 93 | batch: number | null; 94 | migration_time: Date | null; 95 | } 96 | export interface KnexMigrationsInput { 97 | id?: number; 98 | name?: string | null; 99 | batch?: number | null; 100 | migration_time?: Date | null; 101 | } 102 | const knex_migrations = { 103 | tableName: "knex_migrations", 104 | columns: ["id", "name", "batch", "migration_time"], 105 | requiredForInsert: [], 106 | primaryKey: "id", 107 | foreignKeys: {}, 108 | $type: null as unknown as KnexMigrations, 109 | $input: null as unknown as KnexMigrationsInput, 110 | } as const; 111 | 112 | // Table knex_migrations_lock 113 | export interface KnexMigrationsLock { 114 | index: number; 115 | is_locked: number | null; 116 | } 117 | export interface KnexMigrationsLockInput { 118 | index?: number; 119 | is_locked?: number | null; 120 | } 121 | const knex_migrations_lock = { 122 | tableName: "knex_migrations_lock", 123 | columns: ["index", "is_locked"], 124 | requiredForInsert: [], 125 | primaryKey: "index", 126 | foreignKeys: {}, 127 | $type: null as unknown as KnexMigrationsLock, 128 | $input: null as unknown as KnexMigrationsLockInput, 129 | } as const; 130 | 131 | // Table modules 132 | export interface Modules { 133 | id: number; 134 | organization_id: number; 135 | module_name: string; 136 | notify_when: notify_when; 137 | } 138 | export interface ModulesInput { 139 | id?: number; 140 | organization_id: number; 141 | module_name: string; 142 | notify_when: notify_when; 143 | } 144 | const modules = { 145 | tableName: "modules", 146 | columns: ["id", "organization_id", "module_name", "notify_when"], 147 | requiredForInsert: ["organization_id", "module_name", "notify_when"], 148 | primaryKey: "id", 149 | foreignKeys: { 150 | organization_id: { 151 | table: "organizations", 152 | column: "id", 153 | $type: null as unknown as Organizations, 154 | }, 155 | }, 156 | $type: null as unknown as Modules, 157 | $input: null as unknown as ModulesInput, 158 | } as const; 159 | 160 | // Table organizations 161 | export interface Organizations { 162 | id: number; 163 | organization_name: string; 164 | organization_email: string; 165 | organization_password: string; 166 | slack_workspace: string | null; 167 | slack_channel: string | null; 168 | } 169 | export interface OrganizationsInput { 170 | id?: number; 171 | organization_name: string; 172 | organization_email: string; 173 | organization_password: string; 174 | slack_workspace?: string | null; 175 | slack_channel?: string | null; 176 | } 177 | const organizations = { 178 | tableName: "organizations", 179 | columns: [ 180 | "id", 181 | "organization_name", 182 | "organization_email", 183 | "organization_password", 184 | "slack_workspace", 185 | "slack_channel", 186 | ], 187 | requiredForInsert: [ 188 | "organization_name", 189 | "organization_email", 190 | "organization_password", 191 | ], 192 | primaryKey: "id", 193 | foreignKeys: {}, 194 | $type: null as unknown as Organizations, 195 | $input: null as unknown as OrganizationsInput, 196 | } as const; 197 | 198 | // Table sessions 199 | export interface Sessions { 200 | id: string; 201 | created: Date; 202 | organization_id: number | null; 203 | } 204 | export interface SessionsInput { 205 | id: string; 206 | created?: Date; 207 | organization_id?: number | null; 208 | } 209 | const sessions = { 210 | tableName: "sessions", 211 | columns: ["id", "created", "organization_id"], 212 | requiredForInsert: ["id"], 213 | primaryKey: null, 214 | foreignKeys: { 215 | organization_id: { 216 | table: "organizations", 217 | column: "id", 218 | $type: null as unknown as Organizations, 219 | }, 220 | }, 221 | $type: null as unknown as Sessions, 222 | $input: null as unknown as SessionsInput, 223 | } as const; 224 | 225 | export interface TableTypes { 226 | emails: { 227 | select: Emails; 228 | input: EmailsInput; 229 | }; 230 | jobs: { 231 | select: Jobs; 232 | input: JobsInput; 233 | }; 234 | knex_migrations: { 235 | select: KnexMigrations; 236 | input: KnexMigrationsInput; 237 | }; 238 | knex_migrations_lock: { 239 | select: KnexMigrationsLock; 240 | input: KnexMigrationsLockInput; 241 | }; 242 | modules: { 243 | select: Modules; 244 | input: ModulesInput; 245 | }; 246 | organizations: { 247 | select: Organizations; 248 | input: OrganizationsInput; 249 | }; 250 | sessions: { 251 | select: Sessions; 252 | input: SessionsInput; 253 | }; 254 | } 255 | 256 | export const tables = { 257 | emails, 258 | jobs, 259 | knex_migrations, 260 | knex_migrations_lock, 261 | modules, 262 | organizations, 263 | sessions, 264 | }; 265 | -------------------------------------------------------------------------------- /test/notify.spec.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from "knex"; 2 | import { DateTime } from "luxon"; 3 | import { describe, expect, it, test } from "vitest"; 4 | import { 5 | Comparator, 6 | getLatest, 7 | getLatestPrerelease, 8 | isoToUtc, 9 | notify, 10 | NotifyPayload, 11 | VersionHistory, 12 | } from "../src/notify"; 13 | 14 | describe("getLatestPrerelease", () => { 15 | it("returns latest prerelease before a specified date", () => { 16 | const versions: VersionHistory[] = [ 17 | { 18 | version: "1.0.0-alpha.0", 19 | published: "2022-12-16T15:00:00.000Z", 20 | }, 21 | { 22 | version: "0.9.1", 23 | published: "2022-12-17T15:00:00.000Z", 24 | }, 25 | { 26 | version: "1.9.1-alpha.2", 27 | published: "2022-12-20T15:00:00.000Z", 28 | }, 29 | { 30 | version: "1.0.0", 31 | published: "2022-12-20T15:00:00.000Z", 32 | }, 33 | ]; 34 | 35 | const cutoff = "2022-12-17T18:00:00.000Z"; 36 | 37 | const actual = getLatestPrerelease(versions, cutoff); 38 | 39 | expect(actual).toEqual(versions[0]); 40 | }); 41 | }); 42 | 43 | describe("getLatest", () => { 44 | it("returns latest major before a specified date", () => { 45 | const versions: VersionHistory[] = [ 46 | { 47 | version: "1.0.0", 48 | published: "2022-12-16T15:00:00.000Z", 49 | }, 50 | { 51 | version: "2.1.5", 52 | published: "2022-12-17T15:00:00.000Z", 53 | }, 54 | { 55 | version: "3.0.1", 56 | published: "2022-12-20T15:00:00.000Z", 57 | }, 58 | ]; 59 | 60 | const cutoff = "2022-12-18T15:00:00.000Z"; 61 | 62 | const actual = getLatest(versions, cutoff); 63 | 64 | expect(actual).toEqual(versions[1]); 65 | }); 66 | }); 67 | 68 | describe("notify", () => { 69 | describe("major release", () => { 70 | describe("weekly notification", () => { 71 | it("returns module with major release jump", () => { 72 | const payload: NotifyPayload = { 73 | modules: [ 74 | { 75 | npmInfo: { 76 | name: "vite", 77 | time: { 78 | "0.9.0": "2022-12-16T15:00:00.000Z", 79 | "1.0.0": "2022-12-25T15:00:00.000Z", 80 | }, 81 | }, 82 | notifyWhen: "major", 83 | }, 84 | ], 85 | now: "2022-12-27T15:00:00.000Z", 86 | schedule: "weekly", 87 | }; 88 | 89 | const result = notify(payload); 90 | 91 | expect(result).toEqual([ 92 | { 93 | name: "vite", 94 | previousVersion: { 95 | version: "0.9.0", 96 | published: "2022-12-16T15:00:00.000Z", 97 | }, 98 | currentVersion: { 99 | version: "1.0.0", 100 | published: "2022-12-25T15:00:00.000Z", 101 | }, 102 | }, 103 | ]); 104 | }); 105 | 106 | it("returns nothing if no suitable change is found", () => { 107 | const payload: NotifyPayload = { 108 | modules: [ 109 | { 110 | npmInfo: { 111 | name: "vite", 112 | time: { 113 | "0.9.0": "2022-12-16T15:00:00.000Z", 114 | "0.10.0": "2022-12-25T15:00:00.000Z", 115 | }, 116 | }, 117 | notifyWhen: "major", 118 | }, 119 | ], 120 | now: "2022-12-27T15:00:00.000Z", 121 | schedule: "weekly", 122 | }; 123 | 124 | const result = notify(payload); 125 | 126 | expect(result).toEqual([]); 127 | }); 128 | }); 129 | 130 | describe("daily notification", () => { 131 | it("returns module with major release jump", () => { 132 | const payload: NotifyPayload = { 133 | modules: [ 134 | { 135 | npmInfo: { 136 | name: "vite", 137 | time: { 138 | "0.8.0": "2022-12-16T15:00:00.000Z", 139 | "0.9.0": "2022-12-24T15:00:00.000Z", 140 | "1.0.0": "2022-12-25T15:00:00.000Z", 141 | }, 142 | }, 143 | notifyWhen: "major", 144 | }, 145 | ], 146 | now: "2022-12-26T15:00:00.000Z", 147 | schedule: "daily", 148 | }; 149 | 150 | const result = notify(payload); 151 | 152 | expect(result).toEqual([ 153 | { 154 | name: "vite", 155 | previousVersion: { 156 | version: "0.9.0", 157 | published: "2022-12-24T15:00:00.000Z", 158 | }, 159 | currentVersion: { 160 | version: "1.0.0", 161 | published: "2022-12-25T15:00:00.000Z", 162 | }, 163 | }, 164 | ]); 165 | }); 166 | }); 167 | }); 168 | 169 | describe("minor release", () => { 170 | it("returns when minor version jump occurs", () => { 171 | const payload: NotifyPayload = { 172 | modules: [ 173 | { 174 | npmInfo: { 175 | name: "vite", 176 | time: { 177 | "0.8.0": "2022-12-16T15:00:00.000Z", 178 | "0.9.0": "2022-12-24T15:00:00.000Z", 179 | "0.10.0": "2022-12-25T15:00:00.000Z", 180 | }, 181 | }, 182 | notifyWhen: "minor", 183 | }, 184 | ], 185 | now: "2022-12-26T15:00:00.000Z", 186 | schedule: "daily", 187 | }; 188 | 189 | const result = notify(payload); 190 | 191 | expect(result).toEqual([ 192 | { 193 | name: "vite", 194 | previousVersion: { 195 | version: "0.9.0", 196 | published: "2022-12-24T15:00:00.000Z", 197 | }, 198 | currentVersion: { 199 | version: "0.10.0", 200 | published: "2022-12-25T15:00:00.000Z", 201 | }, 202 | }, 203 | ]); 204 | }); 205 | 206 | // If the user wants minor notifications but a major occurs 207 | // we also notify them. When the user opts for "minor" it really 208 | // means "minor AND above" 209 | it("returns when major version jump occurs", () => { 210 | const payload: NotifyPayload = { 211 | modules: [ 212 | { 213 | npmInfo: { 214 | name: "vite", 215 | time: { 216 | "0.9.0": "2022-12-24T15:00:00.000Z", 217 | "1.0.0": "2022-12-25T15:00:00.000Z", 218 | }, 219 | }, 220 | notifyWhen: "minor", 221 | }, 222 | ], 223 | now: "2022-12-26T15:00:00.000Z", 224 | schedule: "daily", 225 | }; 226 | 227 | const result = notify(payload); 228 | 229 | expect(result).toEqual([ 230 | { 231 | name: "vite", 232 | previousVersion: { 233 | version: "0.9.0", 234 | published: "2022-12-24T15:00:00.000Z", 235 | }, 236 | currentVersion: { 237 | version: "1.0.0", 238 | published: "2022-12-25T15:00:00.000Z", 239 | }, 240 | }, 241 | ]); 242 | }); 243 | }); 244 | 245 | describe("pre-release release", () => { 246 | it("returns when version is updated to alpha/beta/release-candiate version", () => { 247 | const payload: NotifyPayload = { 248 | modules: [ 249 | { 250 | npmInfo: { 251 | name: "vite", 252 | time: { 253 | "0.8.0": "2022-12-16T15:00:00.000Z", 254 | "1.0.0": "2022-12-24T15:00:00.000Z", 255 | "2.0.0-alpha.0": "2022-12-25T15:00:00.000Z", 256 | }, 257 | }, 258 | notifyWhen: "prerelease", 259 | }, 260 | ], 261 | now: "2022-12-26T15:00:00.000Z", 262 | schedule: "daily", 263 | }; 264 | 265 | const result = notify(payload); 266 | 267 | expect(result).toEqual([ 268 | { 269 | name: "vite", 270 | previousVersion: { 271 | version: "1.0.0", 272 | published: "2022-12-24T15:00:00.000Z", 273 | }, 274 | currentVersion: { 275 | version: "2.0.0-alpha.0", 276 | published: "2022-12-25T15:00:00.000Z", 277 | }, 278 | }, 279 | ]); 280 | }); 281 | 282 | it("returns when version is updated to alpha -> beta", () => { 283 | const payload: NotifyPayload = { 284 | modules: [ 285 | { 286 | npmInfo: { 287 | name: "vite", 288 | time: { 289 | "0.8.0": "2022-12-16T15:00:00.000Z", 290 | "2.0.0-alpha.0": "2022-12-24T15:00:00.000Z", 291 | "2.0.0-beta.0": "2022-12-25T15:00:00.000Z", 292 | }, 293 | }, 294 | notifyWhen: "prerelease", 295 | }, 296 | ], 297 | now: "2022-12-26T15:00:00.000Z", 298 | schedule: "daily", 299 | }; 300 | 301 | const result = notify(payload); 302 | 303 | expect(result).toEqual([ 304 | { 305 | name: "vite", 306 | previousVersion: { 307 | version: "2.0.0-alpha.0", 308 | published: "2022-12-24T15:00:00.000Z", 309 | }, 310 | currentVersion: { 311 | version: "2.0.0-beta.0", 312 | published: "2022-12-25T15:00:00.000Z", 313 | }, 314 | }, 315 | ]); 316 | }); 317 | 318 | it("returns when version is updated to rc -> major", () => { 319 | const payload: NotifyPayload = { 320 | modules: [ 321 | { 322 | npmInfo: { 323 | name: "vite", 324 | time: { 325 | "0.8.0": "2022-12-16T15:00:00.000Z", 326 | "2.0.0-release-candidate.0": "2022-12-24T15:00:00.000Z", 327 | "2.0.0": "2022-12-25T15:00:00.000Z", 328 | }, 329 | }, 330 | notifyWhen: "prerelease", 331 | }, 332 | ], 333 | now: "2022-12-26T15:00:00.000Z", 334 | schedule: "daily", 335 | }; 336 | 337 | const result = notify(payload); 338 | 339 | expect(result).toEqual([ 340 | { 341 | name: "vite", 342 | previousVersion: { 343 | version: "2.0.0-release-candidate.0", 344 | published: "2022-12-24T15:00:00.000Z", 345 | }, 346 | currentVersion: { 347 | version: "2.0.0", 348 | published: "2022-12-25T15:00:00.000Z", 349 | }, 350 | }, 351 | ]); 352 | }); 353 | }); 354 | }); 355 | --------------------------------------------------------------------------------