Add repository management functionality with CRUD operations

- Implemented repoService for database interactions including count, list, get, create, update, and delete operations.
- Created RepoManager component for managing repositories with a user interface.
- Added forms for creating and editing repositories, including validation and error handling.
- Integrated API calls for fetching, creating, updating, and deleting repositories.
- Enhanced user experience with loading states and action feedback messages.
This commit is contained in:
2026-02-01 04:30:17 +00:00
parent f1ed790cab
commit 29cd473190
13 changed files with 855 additions and 116 deletions

View File

@@ -1,6 +1,7 @@
import crypto from "crypto";
import db from "./db.js";
import { fetchManifest, fetchRepo, normalizeRepoInput } from "./pluginService.js";
import { getRepoById } from "./repoService.js";
function toIso(value) {
return value ? new Date(value).toISOString() : null;
@@ -31,12 +32,29 @@ export async function findLicenseByKey(key) {
return rows[0] || null;
}
export async function createLicense(userId, { label, note, repo }) {
const repoEntry = normalizeRepoInput(repo);
if (!repoEntry) {
const error = new Error("Ongeldige plugin referentie.");
error.meta = "INVALID_REPO";
throw error;
export async function createLicense(userId, { label, note, repo, repoId }) {
let repoEntry = null;
let repoRow = null;
if (repoId) {
repoRow = await getRepoById(repoId);
if (!repoRow) {
const error = new Error("Onbekende repository.");
error.meta = "INVALID_REPO";
throw error;
}
repoEntry = {
repo: repoRow.ownerRepo,
provider: repoRow.provider,
baseUrl: repoRow.baseUrl
};
} else {
repoEntry = normalizeRepoInput(repo);
if (!repoEntry) {
const error = new Error("Ongeldige plugin referentie.");
error.meta = "INVALID_REPO";
throw error;
}
}
let licenseId = null;
@@ -47,9 +65,9 @@ export async function createLicense(userId, { label, note, repo }) {
await db.query(
`INSERT INTO licenses (
id, user_id, license_key, label, note,
repo_provider, repo_name, repo_base_url,
repo_provider, repo_name, repo_base_url, repo_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[
id,
userId,
@@ -58,7 +76,8 @@ export async function createLicense(userId, { label, note, repo }) {
note || null,
repoEntry.provider || "github",
repoEntry.repo,
repoEntry.baseUrl || (repoEntry.provider === "github" ? "https://github.com" : null)
repoEntry.baseUrl || (repoEntry.provider === "github" ? "https://github.com" : null),
repoRow?.id || null
]
);
licenseId = id;
@@ -80,11 +99,24 @@ export async function createLicense(userId, { label, note, repo }) {
export async function buildLicensePayload(row) {
if (!row) return null;
const repoEntry = normalizeRepoInput({
repo: row.repo_name,
provider: row.repo_provider,
baseUrl: row.repo_base_url
});
let repoRow = null;
if (row.repo_id) {
repoRow = await getRepoById(row.repo_id);
}
const repoEntry =
repoRow && repoRow.ownerRepo
? {
repo: repoRow.ownerRepo,
provider: repoRow.provider,
baseUrl: repoRow.baseUrl
}
: normalizeRepoInput({
repo: row.repo_name,
provider: row.repo_provider,
baseUrl: row.repo_base_url
});
const [hostnameRows] = await db.query(
`SELECT hostname, normalized, first_seen_at, last_seen_at, hits
FROM license_hostnames WHERE license_id = ? ORDER BY first_seen_at ASC`,
@@ -112,6 +144,8 @@ export async function buildLicensePayload(row) {
primaryHostnameNormalized: row.primary_hostname_normalized,
repoFullName: row.repo_name,
repoUrl: null,
repoId: repoRow?.id || null,
repoLabel: repoRow?.label || null,
pluginName: row.label,
pluginVersion: row.last_used_version || null,
lastUsedVersion: row.last_used_version,
@@ -134,7 +168,9 @@ export async function buildLicensePayload(row) {
primaryHostnameNormalized: row.primary_hostname_normalized,
repoFullName: info.fullName,
repoUrl: info.repoUrl,
pluginName: manifest?.plugin_name || info.name || row.label,
repoId: repoRow?.id || null,
repoLabel: repoRow?.label || null,
pluginName: manifest?.plugin_name || repoRow?.label || info.name || row.label,
pluginVersion: manifest?.version || row.last_used_version || null,
lastUsedVersion: row.last_used_version,
repo: repoEntry,
@@ -151,9 +187,14 @@ export async function buildLicensePayload(row) {
lastVersionCheckAt: toIso(row.last_version_check_at),
primaryHostname: row.primary_hostname,
primaryHostnameNormalized: row.primary_hostname_normalized,
repoFullName: row.repo_name,
repoUrl: repoEntry?.baseUrl ? `${repoEntry.baseUrl.replace(/\/$/, "")}/${row.repo_name}` : null,
pluginName: row.label,
repoFullName: repoRow?.ownerRepo || row.repo_name,
repoUrl:
(repoRow?.baseUrl || repoEntry?.baseUrl)
? `${(repoRow?.baseUrl || repoEntry.baseUrl).replace(/\/$/, "")}/${repoRow?.ownerRepo || row.repo_name}`
: null,
repoId: repoRow?.id || null,
repoLabel: repoRow?.label || null,
pluginName: repoRow?.label || row.label,
pluginVersion: row.last_used_version || null,
lastUsedVersion: row.last_used_version,
repo: repoEntry,

141
server/lib/repoService.js Normal file
View File

@@ -0,0 +1,141 @@
import db from "./db.js";
function toIso(value) {
return value ? new Date(value).toISOString() : null;
}
function serializeRepo(row) {
if (!row) return null;
return {
id: row.id,
provider: row.provider,
ownerRepo: row.owner_repo,
baseUrl: row.base_url,
label: row.label,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at)
};
}
export async function countRepos() {
const [[{ count }]] = await db.query(`SELECT COUNT(*) AS count FROM repos`);
return Number(count) || 0;
}
export async function listRepos() {
const [rows] = await db.query(`SELECT * FROM repos ORDER BY created_at ASC, id ASC`);
return rows.map(serializeRepo);
}
export async function getRepoById(id) {
if (!id) return null;
const [rows] = await db.query(`SELECT * FROM repos WHERE id = ? LIMIT 1`, [id]);
return serializeRepo(rows[0]);
}
export async function findRepoByOwnerRepo(ownerRepo, provider = null) {
if (!ownerRepo) return null;
if (provider) {
const [rows] = await db.query(
`SELECT * FROM repos WHERE owner_repo = ? AND provider = ? LIMIT 1`,
[ownerRepo, provider.toLowerCase()]
);
return serializeRepo(rows[0]);
}
const [rows] = await db.query(`SELECT * FROM repos WHERE owner_repo = ? LIMIT 1`, [ownerRepo]);
return serializeRepo(rows[0]);
}
export async function createRepo({ provider = "github", ownerRepo, baseUrl = null, label = null }) {
if (!ownerRepo) {
const error = new Error("Repo ontbreekt.");
error.meta = "INVALID_REPO";
throw error;
}
const normalizedProvider = String(provider || "github").toLowerCase();
const trimmedOwnerRepo = ownerRepo.trim();
const sanitizedBaseUrl = baseUrl ? baseUrl.trim() : null;
const safeLabel = label ? label.trim() : null;
try {
const [result] = await db.query(
`INSERT INTO repos (provider, owner_repo, base_url, label)
VALUES (?, ?, ?, ?)`,
[normalizedProvider, trimmedOwnerRepo, sanitizedBaseUrl, safeLabel]
);
return await getRepoById(result.insertId);
} catch (error) {
if (error?.code === "ER_DUP_ENTRY") {
const dup = new Error("Deze repository bestaat al.");
dup.meta = "DUPLICATE";
throw dup;
}
throw error;
}
}
export async function updateRepo(id, { provider, ownerRepo, baseUrl, label }) {
const repo = await getRepoById(id);
if (!repo) {
const error = new Error("Repo niet gevonden.");
error.meta = "NOT_FOUND";
throw error;
}
const fields = [];
const values = [];
if (provider) {
fields.push("provider = ?");
values.push(String(provider).toLowerCase());
}
if (ownerRepo) {
fields.push("owner_repo = ?");
values.push(ownerRepo.trim());
}
if (typeof baseUrl !== "undefined") {
fields.push("base_url = ?");
values.push(baseUrl ? baseUrl.trim() : null);
}
if (typeof label !== "undefined") {
fields.push("label = ?");
values.push(label ? label.trim() : null);
}
if (fields.length === 0) {
return repo;
}
try {
await db.query(`UPDATE repos SET ${fields.join(", ")}, updated_at = NOW() WHERE id = ?`, [...values, id]);
} catch (error) {
if (error?.code === "ER_DUP_ENTRY") {
const dup = new Error("Deze repository bestaat al.");
dup.meta = "DUPLICATE";
throw dup;
}
throw error;
}
return await getRepoById(id);
}
export async function deleteRepo(id) {
const repo = await getRepoById(id);
if (!repo) {
const error = new Error("Repo niet gevonden.");
error.meta = "NOT_FOUND";
throw error;
}
const [[{ count }]] = await db.query(`SELECT COUNT(*) AS count FROM licenses WHERE repo_id = ?`, [id]);
if (Number(count) > 0) {
const error = new Error("Repo is gekoppeld aan bestaande licenties.");
error.meta = "IN_USE";
throw error;
}
await db.query(`DELETE FROM repos WHERE id = ? LIMIT 1`, [id]);
return repo;
}

View File

@@ -37,6 +37,7 @@ async function createLicensesTable() {
repo_provider VARCHAR(32) NOT NULL,
repo_name VARCHAR(255) NOT NULL,
repo_base_url VARCHAR(255),
repo_id INT UNSIGNED NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_version_check_at DATETIME NULL,
@@ -52,6 +53,21 @@ async function createLicensesTable() {
"last_used_version",
"last_used_version VARCHAR(64) NULL AFTER last_version_check_at"
);
await ensureColumn(
"licenses",
"repo_id",
"repo_id INT UNSIGNED NULL AFTER repo_base_url"
);
await db
.query(
`ALTER TABLE licenses
ADD CONSTRAINT fk_licenses_repo_id
FOREIGN KEY (repo_id) REFERENCES repos(id)
ON DELETE SET NULL`
)
.catch(() => {});
}
async function createLicenseHostnamesTable() {
@@ -70,8 +86,24 @@ async function createLicenseHostnamesTable() {
`);
}
async function createReposTable() {
await db.query(`
CREATE TABLE IF NOT EXISTS repos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'github',
owner_repo VARCHAR(255) NOT NULL,
base_url VARCHAR(255),
label VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_repo_provider (provider, owner_repo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
}
export async function ensureSchema() {
await createUsersTable();
await createReposTable();
await createLicensesTable();
await createLicenseHostnamesTable();
}