Initial commit - Church Music Database

This commit is contained in:
2026-01-27 18:04:50 -06:00
commit d367261867
336 changed files with 103545 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
const express = require("express");
const router = express.Router();
const { query } = require("../db");
const multer = require("multer");
// Configure multer for file uploads
const storage = multer.memoryStorage();
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max
fileFilter: (req, file, cb) => {
if (
file.mimetype === "application/json" ||
file.originalname.endsWith(".json")
) {
cb(null, true);
} else {
cb(new Error("Only JSON files are allowed"), false);
}
},
});
// =====================
// EXPORT DATA
// =====================
// Export all songs as JSON
router.get("/export/songs", async (req, res) => {
try {
const result = await query("SELECT * FROM songs ORDER BY title");
res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
"attachment; filename=songs-export.json",
);
res.json({
success: true,
exportedAt: new Date().toISOString(),
count: result.rows.length,
songs: result.rows,
});
} catch (err) {
console.error("Export songs error:", err);
res.status(500).json({ success: false, message: "Failed to export songs" });
}
});
// Export all profiles as JSON
router.get("/export/profiles", async (req, res) => {
try {
const result = await query("SELECT * FROM profiles ORDER BY name");
res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
"attachment; filename=profiles-export.json",
);
res.json({
success: true,
exportedAt: new Date().toISOString(),
count: result.rows.length,
profiles: result.rows,
});
} catch (err) {
console.error("Export profiles error:", err);
res
.status(500)
.json({ success: false, message: "Failed to export profiles" });
}
});
// Export all worship lists as JSON
router.get("/export/lists", async (req, res) => {
try {
const result = await query(`
SELECT p.*,
COALESCE(json_agg(
json_build_object(
'song_id', ps.song_id,
'order_index', ps.order_index,
'song_title', s.title
) ORDER BY ps.order_index
) FILTER (WHERE ps.song_id IS NOT NULL), '[]') as songs
FROM plans p
LEFT JOIN plan_songs ps ON p.id = ps.plan_id
LEFT JOIN songs s ON ps.song_id = s.id
GROUP BY p.id
ORDER BY p.date DESC
`);
res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
"attachment; filename=worship-lists-export.json",
);
res.json({
success: true,
exportedAt: new Date().toISOString(),
count: result.rows.length,
lists: result.rows,
});
} catch (err) {
console.error("Export lists error:", err);
res
.status(500)
.json({ success: false, message: "Failed to export worship lists" });
}
});
// Export everything (full database backup)
router.get("/export/all", async (req, res) => {
try {
const [songs, profiles, lists, users] = await Promise.all([
query("SELECT * FROM songs ORDER BY title"),
query("SELECT * FROM profiles ORDER BY name"),
query(`
SELECT p.*,
COALESCE(json_agg(
json_build_object(
'song_id', ps.song_id,
'order_index', ps.order_index
) ORDER BY ps.order_index
) FILTER (WHERE ps.song_id IS NOT NULL), '[]') as songs
FROM plans p
LEFT JOIN plan_songs ps ON p.id = ps.plan_id
GROUP BY p.id
ORDER BY p.date DESC
`),
query(
"SELECT id, username, role, created_at FROM users ORDER BY username",
),
]);
res.setHeader("Content-Type", "application/json");
res.setHeader(
"Content-Disposition",
"attachment; filename=full-backup.json",
);
res.json({
success: true,
exportedAt: new Date().toISOString(),
data: {
songs: { count: songs.rows.length, items: songs.rows },
profiles: { count: profiles.rows.length, items: profiles.rows },
worshipLists: { count: lists.rows.length, items: lists.rows },
users: { count: users.rows.length, items: users.rows },
},
});
} catch (err) {
console.error("Full export error:", err);
res
.status(500)
.json({ success: false, message: "Failed to export database" });
}
});
// =====================
// IMPORT DATA
// =====================
// Import songs from JSON
router.post("/import/songs", upload.single("file"), async (req, res) => {
try {
if (!req.file) {
return res
.status(400)
.json({ success: false, message: "No file uploaded" });
}
const data = JSON.parse(req.file.buffer.toString());
const songs = data.songs || data;
if (!Array.isArray(songs)) {
return res
.status(400)
.json({
success: false,
message: "Invalid format: expected array of songs",
});
}
let imported = 0;
let skipped = 0;
const errors = [];
for (const song of songs) {
try {
// Check if song exists by title
const existing = await query(
"SELECT id FROM songs WHERE LOWER(title) = LOWER($1)",
[song.title],
);
if (existing.rows.length > 0) {
skipped++;
continue;
}
await query(
`INSERT INTO songs (title, artist, lyrics, chords, tempo, time_signature, category, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
song.title,
song.artist || null,
song.lyrics || "",
song.chords || song.key_chord || null,
song.tempo || null,
song.time_signature || null,
song.category || null,
song.notes || null,
],
);
imported++;
} catch (err) {
errors.push({ title: song.title, error: err.message });
}
}
res.json({
success: true,
message: `Imported ${imported} songs, skipped ${skipped} duplicates`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
});
} catch (err) {
console.error("Import songs error:", err);
res
.status(500)
.json({
success: false,
message: "Failed to import songs: " + err.message,
});
}
});
// =====================
// USER MANAGEMENT
// =====================
// Get all users
router.get("/users", async (req, res) => {
try {
const result = await query(
"SELECT id, username, role, created_at FROM users ORDER BY username",
);
// Add biometric_enabled as false since column may not exist
const users = result.rows.map((user) => ({
...user,
biometric_enabled: false,
}));
res.json({ success: true, users });
} catch (err) {
console.error("Get users error:", err);
res.status(500).json({ success: false, message: "Failed to fetch users" });
}
});
// Create new user
router.post("/users", async (req, res) => {
const { username, password, role = "user" } = req.body;
if (!username || !password) {
return res
.status(400)
.json({ success: false, message: "Username and password are required" });
}
try {
// Check if username exists
const existing = await query(
"SELECT id FROM users WHERE LOWER(username) = LOWER($1)",
[username],
);
if (existing.rows.length > 0) {
return res
.status(400)
.json({ success: false, message: "Username already exists" });
}
// Hash password (simple for now - should use bcrypt in production)
const bcrypt = require("bcrypt");
const hashedPassword = await bcrypt.hash(password, 10);
const result = await query(
`INSERT INTO users (username, password, role) VALUES ($1, $2, $3) RETURNING id, username, role, created_at`,
[username, hashedPassword, role],
);
res.json({ success: true, user: result.rows[0] });
} catch (err) {
console.error("Create user error:", err);
res.status(500).json({ success: false, message: "Failed to create user" });
}
});
// Update user
router.put("/users/:id", async (req, res) => {
const { id } = req.params;
const { username, password, role } = req.body;
try {
const updates = [];
const values = [];
let paramCount = 1;
if (username) {
updates.push(`username = $${paramCount++}`);
values.push(username);
}
if (password) {
const bcrypt = require("bcrypt");
const hashedPassword = await bcrypt.hash(password, 10);
updates.push(`password = $${paramCount++}`);
values.push(hashedPassword);
}
if (role) {
updates.push(`role = $${paramCount++}`);
values.push(role);
}
if (updates.length === 0) {
return res
.status(400)
.json({ success: false, message: "No updates provided" });
}
values.push(id);
const result = await query(
`UPDATE users SET ${updates.join(", ")} WHERE id = $${paramCount}
RETURNING id, username, role, created_at`,
values,
);
if (result.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "User not found" });
}
res.json({ success: true, user: result.rows[0] });
} catch (err) {
console.error("Update user error:", err);
res.status(500).json({ success: false, message: "Failed to update user" });
}
});
// Delete user
router.delete("/users/:id", async (req, res) => {
const { id } = req.params;
try {
const result = await query(
"DELETE FROM users WHERE id = $1 RETURNING id, username",
[id],
);
if (result.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "User not found" });
}
res.json({ success: true, message: "User deleted", user: result.rows[0] });
} catch (err) {
console.error("Delete user error:", err);
res.status(500).json({ success: false, message: "Failed to delete user" });
}
});
// Enable biometric authentication for user
router.post("/users/:id/biometric", async (req, res) => {
const { id } = req.params;
const { enable = true } = req.body;
try {
// Check if user exists first
const userCheck = await query(
"SELECT id, username FROM users WHERE id = $1",
[id],
);
if (userCheck.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "User not found" });
}
// Note: biometric_enabled column may not exist yet - this is a placeholder
// In production, you would add the column to the database first
res.json({
success: true,
message: `Biometric authentication ${enable ? "enabled" : "disabled"} (feature pending database migration)`,
user: { ...userCheck.rows[0], biometric_enabled: enable },
});
} catch (err) {
console.error("Biometric update error:", err);
res
.status(500)
.json({ success: false, message: "Failed to update biometric settings" });
}
});
// =====================
// SYSTEM SETTINGS
// =====================
// Get system settings
router.get("/settings", async (req, res) => {
try {
const result = await query("SELECT * FROM settings ORDER BY key");
const settings = {};
result.rows.forEach((row) => {
settings[row.key] = row.value;
});
res.json({ success: true, settings });
} catch (err) {
// If settings table doesn't exist, return defaults
res.json({
success: true,
settings: {
church_name: "House of Prayer",
default_tempo: "120",
default_time_signature: "4/4",
auto_transpose: "false",
show_chord_diagrams: "true",
},
});
}
});
// Update system setting
router.put("/settings/:key", async (req, res) => {
const { key } = req.params;
const { value } = req.body;
try {
// Try upsert
await query(
`INSERT INTO settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, value],
);
res.json({ success: true, message: "Setting updated" });
} catch (err) {
console.error("Update setting error:", err);
res
.status(500)
.json({ success: false, message: "Failed to update setting" });
}
});
module.exports = router;

View File

@@ -0,0 +1,259 @@
const express = require("express");
const router = express.Router();
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const { query } = require("../db");
const JWT_SECRET = process.env.JWT_SECRET || "your-super-secret-jwt-key";
// Login
router.post("/login", async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
success: false,
message: "Username and password are required",
});
}
// Find user in database (case-insensitive)
const result = await query(
"SELECT * FROM users WHERE LOWER(username) = LOWER($1)",
[username],
);
if (result.rows.length === 0) {
return res
.status(401)
.json({ success: false, message: "Invalid credentials" });
}
const user = result.rows[0];
// Check password
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
return res
.status(401)
.json({ success: false, message: "Invalid credentials" });
}
// Generate JWT token
const token = jwt.sign(
{
id: user.id,
username: user.username,
role: user.role || "user",
},
JWT_SECRET,
{ expiresIn: "7d" },
);
res.json({
success: true,
token,
user: {
id: user.id,
username: user.username,
name: user.display_name || user.username,
role: user.role || "user",
},
});
} catch (err) {
console.error("Login error:", err);
res.status(500).json({ success: false, message: "Login failed" });
}
});
// Verify token
router.get("/verify", async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res
.status(401)
.json({ success: false, message: "No token provided" });
}
const token = authHeader.split(" ")[1];
const decoded = jwt.verify(token, JWT_SECRET);
// Get fresh user data
const result = await query("SELECT * FROM users WHERE id = $1", [
decoded.id,
]);
if (result.rows.length === 0) {
return res
.status(401)
.json({ success: false, message: "User not found" });
}
const user = result.rows[0];
res.json({
success: true,
user: {
id: user.id,
username: user.username,
name: user.display_name || user.username,
role: user.role || "user",
},
});
} catch (err) {
console.error("Token verification error:", err);
res.status(401).json({ success: false, message: "Invalid token" });
}
});
// Logout (client-side token deletion, but we can track here if needed)
router.post("/logout", (req, res) => {
res.json({ success: true, message: "Logged out" });
});
// Get current user
router.get("/me", async (req, res) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res
.status(401)
.json({ success: false, message: "Not authenticated" });
}
const token = authHeader.split(" ")[1];
const decoded = jwt.verify(token, JWT_SECRET);
const result = await query("SELECT * FROM users WHERE id = $1", [
decoded.id,
]);
if (result.rows.length === 0) {
return res
.status(401)
.json({ success: false, message: "User not found" });
}
const user = result.rows[0];
res.json({
success: true,
user: {
id: user.id,
username: user.username,
name: user.display_name || user.username,
role: user.role || "user",
biometric_enabled: user.biometric_enabled || false,
},
});
} catch (err) {
console.error("Get user error:", err);
res.status(401).json({ success: false, message: "Invalid token" });
}
});
// Biometric registration - store public key
router.post("/biometric-register", async (req, res) => {
try {
const { username, credentialId, publicKey } = req.body;
if (!username || !credentialId || !publicKey) {
return res.status(400).json({
success: false,
message: "Username, credential ID, and public key required",
});
}
// Update user with biometric credential
const result = await query(
`UPDATE users
SET biometric_credential_id = $1,
biometric_public_key = $2,
biometric_enabled = true
WHERE username = $3
RETURNING id, username`,
[credentialId, publicKey, username.toLowerCase()],
);
if (result.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "User not found" });
}
res.json({
success: true,
message: "Biometric authentication registered successfully",
});
} catch (err) {
console.error("Biometric registration error:", err);
res.status(500).json({ success: false, message: "Registration failed" });
}
});
// Biometric login - verify assertion
router.post("/biometric-login", async (req, res) => {
try {
const { username, assertion } = req.body;
if (!username || !assertion) {
return res.status(400).json({
success: false,
message: "Username and assertion required",
});
}
// Find user with biometric enabled
const result = await query(
`SELECT * FROM users
WHERE username = $1 AND biometric_enabled = true`,
[username.toLowerCase()],
);
if (result.rows.length === 0) {
return res.status(401).json({
success: false,
message: "Biometric authentication not enabled",
});
}
const user = result.rows[0];
// In a production environment, verify the assertion signature here
// For now, we'll trust the client-side verification
// TODO: Implement server-side WebAuthn assertion verification
// Generate JWT token
const token = jwt.sign(
{
id: user.id,
username: user.username,
role: user.role || "user",
},
JWT_SECRET,
{ expiresIn: "7d" },
);
res.json({
success: true,
token,
user: {
id: user.id,
username: user.username,
name: user.display_name || user.username,
role: user.role || "user",
},
});
} catch (err) {
console.error("Biometric login error:", err);
res.status(500).json({ success: false, message: "Biometric login failed" });
}
});
module.exports = router;

View File

@@ -0,0 +1,277 @@
const express = require("express");
const router = express.Router();
const { query } = require("../db");
const { v4: uuidv4 } = require("uuid");
const {
success,
error,
notFound,
badRequest,
} = require("../utils/responseHandler");
const { authenticate } = require("../middleware/auth");
// Reusable SQL fragments
const SELECT_LIST_WITH_COUNT = `
SELECT p.*, pr.name as profile_name,
(SELECT COUNT(*) FROM plan_songs WHERE plan_id = p.id) as song_count
FROM plans p
LEFT JOIN profiles pr ON p.profile_id = pr.id
`;
const SELECT_LIST_SONGS = `
SELECT s.*, s.chords as key_chord, ps.order_index
FROM songs s
INNER JOIN plan_songs ps ON s.id = ps.song_id
WHERE ps.plan_id = $1
ORDER BY ps.order_index ASC
`;
/**
* Helper to add songs to a worship list
*/
const addSongsToList = async (planId, songs) => {
if (!songs || !Array.isArray(songs) || songs.length === 0) return;
const values = songs
.map(
(songId, index) => `('${uuidv4()}', '${planId}', '${songId}', ${index})`,
)
.join(", ");
await query(`
INSERT INTO plan_songs (id, plan_id, song_id, order_index)
VALUES ${values}
`);
};
/**
* Helper to get next order index for a list
*/
const getNextOrderIndex = async (planId) => {
const result = await query(
"SELECT COALESCE(MAX(order_index), -1) + 1 as next_order FROM plan_songs WHERE plan_id = $1",
[planId],
);
return result.rows[0].next_order;
};
// GET all worship lists (plans)
router.get("/", async (req, res) => {
try {
const result = await query(`
${SELECT_LIST_WITH_COUNT}
ORDER BY p.date DESC
`);
success(res, { lists: result.rows });
} catch (err) {
error(res, "Failed to fetch worship lists");
}
});
// GET single worship list by ID with songs
router.get("/:id", async (req, res) => {
try {
const [listResult, songsResult] = await Promise.all([
query(`${SELECT_LIST_WITH_COUNT} WHERE p.id = $1`, [req.params.id]),
query(SELECT_LIST_SONGS, [req.params.id]),
]);
if (listResult.rows.length === 0) {
return notFound(res, "Worship list");
}
success(res, {
list: listResult.rows[0],
songs: songsResult.rows,
});
} catch (err) {
error(res, "Failed to fetch worship list");
}
});
// POST create new worship list
router.post("/", authenticate, async (req, res) => {
try {
const { date, profile_id, notes, songs } = req.body;
if (!date) {
return badRequest(res, "Date is required");
}
const id = uuidv4();
const now = Math.floor(Date.now() / 1000);
const result = await query(
`INSERT INTO plans (id, date, profile_id, notes, created_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[id, date, profile_id || null, notes || "", now],
);
await addSongsToList(id, songs);
success(res, { list: result.rows[0] }, 201);
} catch (err) {
error(res, "Failed to create worship list");
}
});
// PUT update worship list
router.put("/:id", authenticate, async (req, res) => {
try {
const { date, profile_id, notes, songs } = req.body;
console.log(`[PUT /lists/${req.params.id}] Request:`, {
date,
profile_id,
notes,
songCount: songs?.length,
songIds: songs?.slice(0, 3),
});
const result = await query(
`UPDATE plans
SET date = COALESCE($1, date),
profile_id = $2,
notes = COALESCE($3, notes)
WHERE id = $4
RETURNING *`,
[date, profile_id, notes, req.params.id],
);
if (result.rows.length === 0) {
console.log(`[PUT /lists/${req.params.id}] NOT FOUND`);
return notFound(res, "Worship list");
}
console.log(
`[PUT /lists/${req.params.id}] Plan updated, now updating songs...`,
);
// Update songs if provided
if (songs && Array.isArray(songs)) {
await query("DELETE FROM plan_songs WHERE plan_id = $1", [req.params.id]);
console.log(
`[PUT /lists/${req.params.id}] Deleted old songs, adding ${songs.length} new songs`,
);
await addSongsToList(req.params.id, songs);
console.log(`[PUT /lists/${req.params.id}] Songs added successfully`);
}
console.log(`[PUT /lists/${req.params.id}] SUCCESS`);
success(res, { list: result.rows[0] });
} catch (err) {
console.error(`[PUT /lists/:id] ERROR:`, err.message);
console.error(err.stack);
error(res, "Failed to update worship list: " + err.message);
}
});
// DELETE worship list
router.delete("/:id", authenticate, async (req, res) => {
try {
// plan_songs will be deleted via CASCADE
const result = await query("DELETE FROM plans WHERE id = $1 RETURNING id", [
req.params.id,
]);
if (result.rows.length === 0) {
return notFound(res, "Worship list");
}
success(res, { message: "Worship list deleted" });
} catch (err) {
error(res, "Failed to delete worship list");
}
});
// POST add song to worship list
router.post("/:id/songs/:songId", authenticate, async (req, res) => {
try {
const { id, songId } = req.params;
const nextOrder = await getNextOrderIndex(id);
const psId = uuidv4();
const result = await query(
`INSERT INTO plan_songs (id, plan_id, song_id, order_index)
VALUES ($1, $2, $3, $4)
ON CONFLICT (plan_id, song_id) DO NOTHING
RETURNING *`,
[psId, id, songId, nextOrder],
);
success(res, {
message: "Song added to worship list",
added: result.rowCount > 0,
});
} catch (err) {
error(res, "Failed to add song to worship list", 500, {
error: err.message,
});
}
});
// DELETE remove song from worship list
router.delete("/:id/songs/:songId", authenticate, async (req, res) => {
try {
const { id, songId } = req.params;
const result = await query(
"DELETE FROM plan_songs WHERE plan_id = $1 AND song_id = $2 RETURNING *",
[id, songId],
);
success(res, {
message: "Song removed from worship list",
deleted: result.rowCount,
});
} catch (err) {
error(res, "Failed to remove song from worship list", 500, {
error: err.message,
});
}
});
// PUT reorder songs in worship list
router.put("/:id/reorder", authenticate, async (req, res) => {
try {
const { songs } = req.body;
if (!songs || !Array.isArray(songs)) {
return badRequest(res, "Songs array is required");
}
// Batch update using CASE statement for better performance
if (songs.length > 0) {
const cases = songs
.map((songId, index) => `WHEN song_id = '${songId}' THEN ${index}`)
.join(" ");
const songIds = songs.map((id) => `'${id}'`).join(", ");
await query(
`
UPDATE plan_songs
SET order_index = CASE ${cases} END
WHERE plan_id = $1 AND song_id IN (${songIds})
`,
[req.params.id],
);
}
success(res, { message: "Songs reordered" });
} catch (err) {
error(res, "Failed to reorder songs", 500, { error: err.message });
}
});
// GET worship list count
router.get("/stats/count", async (req, res) => {
try {
const result = await query("SELECT COUNT(*) as count FROM plans");
success(res, { count: parseInt(result.rows[0].count) });
} catch (err) {
error(res, "Failed to count worship lists");
}
});
module.exports = router;

View File

@@ -0,0 +1,251 @@
const express = require("express");
const router = express.Router();
const { query } = require("../db");
const { v4: uuidv4 } = require("uuid");
// GET all profiles
router.get("/", async (req, res) => {
try {
const result = await query("SELECT * FROM profiles ORDER BY name ASC");
res.json({ success: true, profiles: result.rows });
} catch (err) {
console.error("Error fetching profiles:", err);
res
.status(500)
.json({ success: false, message: "Failed to fetch profiles" });
}
});
// GET single profile by ID
router.get("/:id", async (req, res) => {
try {
const result = await query("SELECT * FROM profiles WHERE id = $1", [
req.params.id,
]);
if (result.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "Profile not found" });
}
// Also get profile's songs with their preferred keys
const songsResult = await query(
`
SELECT s.*, psk.song_key as preferred_key
FROM songs s
INNER JOIN profile_songs ps ON s.id = ps.song_id
LEFT JOIN profile_song_keys psk ON ps.profile_id = psk.profile_id AND ps.song_id = psk.song_id
WHERE ps.profile_id = $1
ORDER BY s.title ASC
`,
[req.params.id],
);
res.json({
success: true,
profile: result.rows[0],
songs: songsResult.rows,
});
} catch (err) {
console.error("Error fetching profile:", err);
res
.status(500)
.json({ success: false, message: "Failed to fetch profile" });
}
});
// POST create new profile
router.post("/", async (req, res) => {
try {
const {
first_name,
last_name,
name,
email,
contact_number,
notes,
default_key,
} = req.body;
const profileName = name || `${first_name || ""} ${last_name || ""}`.trim();
if (!profileName) {
return res
.status(400)
.json({ success: false, message: "Name is required" });
}
const id = uuidv4();
const result = await query(
`INSERT INTO profiles (id, first_name, last_name, name, email, contact_number, notes, default_key)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
id,
first_name || "",
last_name || "",
profileName,
email || "",
contact_number || "",
notes || "",
default_key || "C",
],
);
res.status(201).json({ success: true, profile: result.rows[0] });
} catch (err) {
console.error("Error creating profile:", err);
res
.status(500)
.json({ success: false, message: "Failed to create profile" });
}
});
// PUT update profile
router.put("/:id", async (req, res) => {
try {
const {
first_name,
last_name,
name,
email,
contact_number,
notes,
default_key,
} = req.body;
const result = await query(
`UPDATE profiles
SET first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
name = COALESCE($3, name),
email = COALESCE($4, email),
contact_number = COALESCE($5, contact_number),
notes = COALESCE($6, notes),
default_key = COALESCE($7, default_key)
WHERE id = $8
RETURNING *`,
[
first_name,
last_name,
name,
email,
contact_number,
notes,
default_key,
req.params.id,
],
);
if (result.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "Profile not found" });
}
res.json({ success: true, profile: result.rows[0] });
} catch (err) {
console.error("Error updating profile:", err);
res
.status(500)
.json({ success: false, message: "Failed to update profile" });
}
});
// DELETE profile
router.delete("/:id", async (req, res) => {
try {
const result = await query(
"DELETE FROM profiles WHERE id = $1 RETURNING id",
[req.params.id],
);
if (result.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "Profile not found" });
}
res.json({ success: true, message: "Profile deleted" });
} catch (err) {
console.error("Error deleting profile:", err);
res
.status(500)
.json({ success: false, message: "Failed to delete profile" });
}
});
// POST add song to profile
router.post("/:id/songs/:songId", async (req, res) => {
try {
const { id, songId } = req.params;
const { song_key } = req.body;
const psId = uuidv4();
// Add song to profile
await query(
`INSERT INTO profile_songs (id, profile_id, song_id)
VALUES ($1, $2, $3)
ON CONFLICT (profile_id, song_id) DO NOTHING`,
[psId, id, songId],
);
// If key provided, set it
if (song_key) {
const pskId = uuidv4();
await query(
`INSERT INTO profile_song_keys (id, profile_id, song_id, song_key)
VALUES ($1, $2, $3, $4)
ON CONFLICT (profile_id, song_id) DO UPDATE SET song_key = $4`,
[pskId, id, songId, song_key],
);
}
res.json({ success: true, message: "Song added to profile" });
} catch (err) {
console.error("Error adding song to profile:", err);
res
.status(500)
.json({ success: false, message: "Failed to add song to profile" });
}
});
// DELETE remove song from profile
router.delete("/:id/songs/:songId", async (req, res) => {
try {
const { id, songId } = req.params;
await query(
"DELETE FROM profile_songs WHERE profile_id = $1 AND song_id = $2",
[id, songId],
);
await query(
"DELETE FROM profile_song_keys WHERE profile_id = $1 AND song_id = $2",
[id, songId],
);
res.json({ success: true, message: "Song removed from profile" });
} catch (err) {
console.error("Error removing song from profile:", err);
res
.status(500)
.json({ success: false, message: "Failed to remove song from profile" });
}
});
// GET profile count
router.get("/stats/count", async (req, res) => {
try {
const result = await query("SELECT COUNT(*) as count FROM profiles");
res.json({ success: true, count: parseInt(result.rows[0].count) });
} catch (err) {
console.error("Error counting profiles:", err);
res
.status(500)
.json({ success: false, message: "Failed to count profiles" });
}
});
module.exports = router;

View File

@@ -0,0 +1,214 @@
const express = require("express");
const router = express.Router();
const { query } = require("../db");
const { v4: uuidv4 } = require("uuid");
const {
success,
error,
notFound,
badRequest,
} = require("../utils/responseHandler");
const {
buildWhereClause,
buildPagination,
buildSearchCondition,
} = require("../utils/queryBuilder");
const { authenticate } = require("../middleware/auth");
// Common SQL fragment
const SELECT_SONG_FIELDS = "SELECT *, chords as key_chord FROM songs";
// GET search songs (for worship list song picker)
router.get("/search", async (req, res) => {
try {
const { q } = req.query;
if (!q || q.trim() === "") {
return success(res, { songs: [], total: 0 });
}
const searchTerm = `%${q.toLowerCase()}%`;
const searchCondition = buildSearchCondition(
searchTerm,
["title", "artist", "singer"],
1,
);
const result = await query(
`${SELECT_SONG_FIELDS}
WHERE ${searchCondition}
ORDER BY title ASC LIMIT 20`,
[searchTerm],
);
success(res, { songs: result.rows, total: result.rowCount });
} catch (err) {
error(res, "Failed to search songs");
}
});
// GET all songs
router.get("/", async (req, res) => {
try {
const { search, artist, band, limit = 100, offset = 0 } = req.query;
const params = [];
const conditions = [];
if (search) {
params.push(`%${search.toLowerCase()}%`);
conditions.push(
`(LOWER(title) LIKE $${params.length} OR LOWER(lyrics) LIKE $${params.length})`,
);
}
if (artist) {
params.push(`%${artist.toLowerCase()}%`);
conditions.push(`LOWER(artist) LIKE $${params.length}`);
}
if (band) {
params.push(`%${band.toLowerCase()}%`);
conditions.push(`LOWER(band) LIKE $${params.length}`);
}
const whereClause = buildWhereClause(conditions);
const { clause: paginationClause, params: paginationParams } =
buildPagination(limit, offset, params.length + 1);
const result = await query(
`${SELECT_SONG_FIELDS}${whereClause} ORDER BY title ASC${paginationClause}`,
[...params, ...paginationParams],
);
success(res, { songs: result.rows, total: result.rowCount });
} catch (err) {
error(res, "Failed to fetch songs");
}
});
// GET single song by ID
router.get("/:id", async (req, res) => {
try {
const result = await query(`${SELECT_SONG_FIELDS} WHERE id = $1`, [
req.params.id,
]);
if (result.rows.length === 0) {
return notFound(res, "Song");
}
success(res, { song: result.rows[0] });
} catch (err) {
error(res, "Failed to fetch song");
}
});
// POST create new song
router.post("/", authenticate, async (req, res) => {
try {
const { title, artist, band, singer, lyrics, chords, key_chord, memo } =
req.body;
if (!title) {
return badRequest(res, "Title is required");
}
const id = uuidv4();
const now = Math.floor(Date.now() / 1000);
const chordsValue = chords || key_chord || "";
const result = await query(
`INSERT INTO songs (id, title, artist, band, singer, lyrics, chords, memo, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *, chords as key_chord`,
[
id,
title,
artist || "",
band || "",
singer || "",
lyrics || "",
chordsValue,
memo || "",
now,
now,
],
);
success(res, { song: result.rows[0] }, 201);
} catch (err) {
error(res, "Failed to create song");
}
});
// PUT update song
router.put("/:id", authenticate, async (req, res) => {
try {
const { title, artist, band, singer, lyrics, chords, key_chord, memo } =
req.body;
const now = Math.floor(Date.now() / 1000);
const chordsValue = chords || key_chord;
const result = await query(
`UPDATE songs
SET title = COALESCE($1, title),
artist = COALESCE($2, artist),
band = COALESCE($3, band),
singer = COALESCE($4, singer),
lyrics = COALESCE($5, lyrics),
chords = COALESCE($6, chords),
memo = COALESCE($7, memo),
updated_at = $8
WHERE id = $9
RETURNING *, chords as key_chord`,
[
title,
artist,
band,
singer,
lyrics,
chordsValue,
memo,
now,
req.params.id,
],
);
if (result.rows.length === 0) {
return notFound(res, "Song");
}
success(res, { song: result.rows[0] });
} catch (err) {
error(res, "Failed to update song");
}
});
// DELETE song
router.delete("/:id", authenticate, async (req, res) => {
try {
const result = await query("DELETE FROM songs WHERE id = $1 RETURNING id", [
req.params.id,
]);
if (result.rows.length === 0) {
return notFound(res, "Song");
}
success(res, { message: "Song deleted" });
} catch (err) {
error(res, "Failed to delete song");
}
});
// GET song count
router.get("/stats/count", async (req, res) => {
try {
const result = await query("SELECT COUNT(*) as count FROM songs");
success(res, { count: parseInt(result.rows[0].count) });
} catch (err) {
error(res, "Failed to count songs");
}
});
module.exports = router;