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,27 @@
# Server Configuration
PORT=8080
NODE_ENV=development
# Database
MONGODB_URI=mongodb://localhost:27017/worship-platform
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:8080/api/auth/google/callback
# WebAuthn
RP_NAME=Worship Platform
RP_ID=localhost
RP_ORIGIN=http://localhost:5100
# CORS
CORS_ORIGIN=http://localhost:5100
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=100

View File

@@ -0,0 +1,309 @@
import express from "express";
import { isAdmin } from "../middleware/auth.js";
const router = express.Router();
// Apply admin check to all routes
router.use(isAdmin);
// In-memory stores (replace with database in production)
const activityLogs = [
{
id: "1",
action: "User login",
user: "Pastor John",
timestamp: new Date().toISOString(),
ip: "192.168.1.100",
status: "success",
},
{
id: "2",
action: "Song created",
user: "Sarah Miller",
timestamp: new Date().toISOString(),
details: "New song: Blessed Be Your Name",
status: "success",
},
{
id: "3",
action: "Failed login attempt",
user: "unknown@test.com",
timestamp: new Date().toISOString(),
ip: "203.45.67.89",
status: "failed",
},
];
const users = new Map([
[
"1",
{
id: "1",
name: "Pastor John",
email: "john@church.org",
role: "admin",
status: "active",
lastLogin: new Date().toISOString(),
},
],
[
"2",
{
id: "2",
name: "Sarah Miller",
email: "sarah@church.org",
role: "leader",
status: "active",
lastLogin: new Date().toISOString(),
},
],
]);
const devices = new Map([
[
"1",
{
id: "1",
name: "iPad Pro - Worship",
userId: "2",
type: "tablet",
lastActive: new Date().toISOString(),
status: "online",
},
],
[
"2",
{
id: "2",
name: "MacBook Pro",
userId: "1",
type: "desktop",
lastActive: new Date().toISOString(),
status: "online",
},
],
]);
// Dashboard stats
router.get("/stats", (req, res) => {
res.json({
totalUsers: users.size,
activeDevices: Array.from(devices.values()).filter(
(d) => d.status === "online",
).length,
actionsToday: activityLogs.filter((log) => {
const logDate = new Date(log.timestamp).toDateString();
return logDate === new Date().toDateString();
}).length,
securityAlerts: activityLogs.filter((log) => log.status === "failed")
.length,
});
});
// Get all users
router.get("/users", (req, res) => {
const result = Array.from(users.values());
res.json({ users: result, total: result.length });
});
// Get single user
router.get("/users/:id", (req, res) => {
const user = users.get(req.params.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({ user });
});
// Create user
router.post("/users", (req, res) => {
const { name, email, role } = req.body;
if (!name || !email) {
return res.status(400).json({ error: "Name and email are required" });
}
const user = {
id: Date.now().toString(),
name,
email,
role: role || "volunteer",
status: "active",
lastLogin: null,
createdAt: new Date().toISOString(),
};
users.set(user.id, user);
// Log activity
activityLogs.push({
id: Date.now().toString(),
action: "User created",
user: req.user.name,
timestamp: new Date().toISOString(),
details: `Created user: ${name}`,
status: "success",
});
res.status(201).json({ message: "User created", user });
});
// Update user
router.put("/users/:id", (req, res) => {
const user = users.get(req.params.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const { name, email, role, status } = req.body;
const updatedUser = {
...user,
name: name || user.name,
email: email || user.email,
role: role || user.role,
status: status || user.status,
};
users.set(user.id, updatedUser);
res.json({ message: "User updated", user: updatedUser });
});
// Delete user
router.delete("/users/:id", (req, res) => {
const user = users.get(req.params.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.role === "admin") {
return res.status(403).json({ error: "Cannot delete admin user" });
}
users.delete(req.params.id);
res.json({ message: "User deleted" });
});
// Get devices
router.get("/devices", (req, res) => {
const result = Array.from(devices.values()).map((device) => ({
...device,
user: users.get(device.userId)?.name || "Unknown",
}));
res.json({ devices: result, total: result.length });
});
// Revoke device
router.delete("/devices/:id", (req, res) => {
const device = devices.get(req.params.id);
if (!device) {
return res.status(404).json({ error: "Device not found" });
}
devices.delete(req.params.id);
res.json({ message: "Device revoked" });
});
// Get activity logs
router.get("/logs", (req, res) => {
const { action, status, limit = 50 } = req.query;
let result = [...activityLogs];
if (action) {
result = result.filter((log) =>
log.action.toLowerCase().includes(action.toLowerCase()),
);
}
if (status) {
result = result.filter((log) => log.status === status);
}
// Sort by timestamp (newest first)
result.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
// Limit results
result = result.slice(0, parseInt(limit));
res.json({ logs: result, total: result.length });
});
// Get roles
router.get("/roles", (req, res) => {
const roles = [
{
id: "admin",
name: "Administrator",
description: "Full access to all features",
permissions: ["all"],
userCount: Array.from(users.values()).filter((u) => u.role === "admin")
.length,
},
{
id: "leader",
name: "Worship Leader",
description: "Manage songs and worship lists",
permissions: [
"songs.read",
"songs.write",
"lists.read",
"lists.write",
"profiles.read",
],
userCount: Array.from(users.values()).filter((u) => u.role === "leader")
.length,
},
{
id: "tech",
name: "Tech Team",
description: "View and present songs",
permissions: ["songs.read", "lists.read"],
userCount: Array.from(users.values()).filter((u) => u.role === "tech")
.length,
},
{
id: "volunteer",
name: "Volunteer",
description: "Basic access",
permissions: ["songs.read"],
userCount: Array.from(users.values()).filter(
(u) => u.role === "volunteer",
).length,
},
];
res.json({ roles });
});
// Security settings
router.get("/security", (req, res) => {
res.json({
twoFactorRequired: false,
sessionTimeout: 3600, // 1 hour
passwordMinLength: 8,
passwordRequireNumber: true,
ipWhitelist: [],
failedLoginAlerts: 3,
});
});
router.put("/security", (req, res) => {
// Update security settings
const {
twoFactorRequired,
sessionTimeout,
passwordMinLength,
passwordRequireNumber,
ipWhitelist,
} = req.body;
// In a real app, save to database
res.json({ message: "Security settings updated" });
});
export default router;

View File

@@ -0,0 +1,380 @@
import express from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { v4 as uuidv4 } from "uuid";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import {
validate,
loginValidation,
registerValidation,
} from "../middleware/validate.js";
const router = express.Router();
// In-memory store (replace with database in production)
const users = new Map();
const webAuthnCredentials = new Map();
const sessions = new Map();
// Helper to generate JWT
const generateToken = (user) => {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role, name: user.name },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || "7d" },
);
};
// Register
router.post(
"/register",
validate(registerValidation),
async (req, res, next) => {
try {
const { name, email, password } = req.body;
// Check if user exists
const existingUser = Array.from(users.values()).find(
(u) => u.email === email,
);
if (existingUser) {
return res.status(400).json({ error: "Email already registered" });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create user
const user = {
id: uuidv4(),
name,
email,
password: hashedPassword,
role: users.size === 0 ? "admin" : "volunteer", // First user is admin
createdAt: new Date().toISOString(),
};
users.set(user.id, user);
// Generate token
const token = generateToken(user);
res.status(201).json({
message: "User registered successfully",
token,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
});
} catch (error) {
next(error);
}
},
);
// Login
router.post("/login", validate(loginValidation), async (req, res, next) => {
try {
const { email, password } = req.body;
// Find user
const user = Array.from(users.values()).find((u) => u.email === email);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Check password
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Generate token
const token = generateToken(user);
// Create session
const sessionId = uuidv4();
sessions.set(sessionId, {
userId: user.id,
createdAt: new Date().toISOString(),
lastActive: new Date().toISOString(),
});
res.json({
message: "Login successful",
token,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
});
} catch (error) {
next(error);
}
});
// Get current user
router.get("/me", (req, res) => {
// This route should be protected - req.user comes from auth middleware
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
const user = users.get(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({
user: { id: user.id, name: user.name, email: user.email, role: user.role },
});
});
// Logout
router.post("/logout", (req, res) => {
// Invalidate session on client side
res.json({ message: "Logged out successfully" });
});
// Google OAuth
router.post("/google", async (req, res, next) => {
try {
const { token } = req.body;
// Verify Google token (simplified - use passport-google-oauth20 in production)
// For now, return mock response
res.json({
message: "Google OAuth not yet configured",
token: null,
user: null,
});
} catch (error) {
next(error);
}
});
// WebAuthn Registration Options
router.post("/webauthn/register-options", async (req, res, next) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Authentication required" });
}
const user = users.get(req.user.id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const options = await generateRegistrationOptions({
rpName: process.env.RP_NAME || "Worship Platform",
rpID: process.env.RP_ID || "localhost",
userID: user.id,
userName: user.email,
userDisplayName: user.name,
attestationType: "none",
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
// Store challenge for verification
sessions.set(`webauthn-${user.id}`, {
challenge: options.challenge,
createdAt: new Date().toISOString(),
});
res.json(options);
} catch (error) {
next(error);
}
});
// WebAuthn Registration Verification
router.post("/webauthn/register", async (req, res, next) => {
try {
if (!req.user) {
return res.status(401).json({ error: "Authentication required" });
}
const session = sessions.get(`webauthn-${req.user.id}`);
if (!session) {
return res.status(400).json({ error: "Registration session expired" });
}
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: session.challenge,
expectedOrigin: process.env.RP_ORIGIN || "http://localhost:5100",
expectedRPID: process.env.RP_ID || "localhost",
});
if (!verification.verified) {
return res
.status(400)
.json({ error: "Registration verification failed" });
}
// Store credential
const { registrationInfo } = verification;
webAuthnCredentials.set(req.user.id, {
credentialID: registrationInfo.credentialID,
credentialPublicKey: registrationInfo.credentialPublicKey,
counter: registrationInfo.counter,
transports: req.body.response.transports,
});
// Clean up session
sessions.delete(`webauthn-${req.user.id}`);
res.json({ message: "Biometric registration successful" });
} catch (error) {
next(error);
}
});
// WebAuthn Authentication Options
router.post("/webauthn/authenticate-options", async (req, res, next) => {
try {
const { email } = req.body;
// Find user by email or use session
const user = email
? Array.from(users.values()).find((u) => u.email === email)
: null;
const options = await generateAuthenticationOptions({
rpID: process.env.RP_ID || "localhost",
userVerification: "preferred",
allowCredentials:
user && webAuthnCredentials.has(user.id)
? [
{
id: webAuthnCredentials.get(user.id).credentialID,
type: "public-key",
transports: webAuthnCredentials.get(user.id).transports,
},
]
: [],
});
// Store challenge
const sessionKey = user ? `webauthn-auth-${user.id}` : `webauthn-auth-temp`;
sessions.set(sessionKey, {
challenge: options.challenge,
userId: user?.id,
createdAt: new Date().toISOString(),
});
res.json(options);
} catch (error) {
next(error);
}
});
// WebAuthn Authentication Verification
router.post("/webauthn/authenticate", async (req, res, next) => {
try {
// Find credential and user
let userId = null;
let credential = null;
for (const [id, cred] of webAuthnCredentials.entries()) {
if (
Buffer.compare(
cred.credentialID,
Buffer.from(req.body.id, "base64url"),
) === 0
) {
userId = id;
credential = cred;
break;
}
}
if (!userId || !credential) {
return res.status(400).json({ error: "Credential not found" });
}
const session = sessions.get(`webauthn-auth-${userId}`);
if (!session) {
return res.status(400).json({ error: "Authentication session expired" });
}
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: session.challenge,
expectedOrigin: process.env.RP_ORIGIN || "http://localhost:5100",
expectedRPID: process.env.RP_ID || "localhost",
authenticator: credential,
});
if (!verification.verified) {
return res
.status(400)
.json({ error: "Authentication verification failed" });
}
// Update counter
credential.counter = verification.authenticationInfo.newCounter;
const user = users.get(userId);
const token = generateToken(user);
// Clean up session
sessions.delete(`webauthn-auth-${userId}`);
res.json({
message: "Biometric authentication successful",
token,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
});
} catch (error) {
next(error);
}
});
// Switch profile
router.post("/switch-profile", (req, res, next) => {
try {
const { profileId } = req.body;
// In a real app, this would switch to a different profile/user context
const user = users.get(profileId);
if (!user) {
return res.status(404).json({ error: "Profile not found" });
}
const token = generateToken(user);
res.json({
message: "Profile switched successfully",
token,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,241 @@
import express from "express";
import { v4 as uuidv4 } from "uuid";
import { validate, listValidation } from "../middleware/validate.js";
const router = express.Router();
// In-memory store (replace with database in production)
const lists = new Map([
[
"1",
{
id: "1",
name: "Sunday Morning",
date: "2026-01-26",
songs: ["1", "2"],
createdBy: "system",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
[
"2",
{
id: "2",
name: "Wednesday Night",
date: "2026-01-22",
songs: ["2"],
createdBy: "system",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
]);
// Get all lists
router.get("/", (req, res) => {
const { search, upcoming } = req.query;
let result = Array.from(lists.values());
// Search filter
if (search) {
const searchLower = search.toLowerCase();
result = result.filter((list) =>
list.name.toLowerCase().includes(searchLower),
);
}
// Upcoming filter
if (upcoming === "true") {
const today = new Date().toISOString().split("T")[0];
result = result.filter((list) => list.date >= today);
}
// Sort by date (newest first)
result.sort((a, b) => new Date(b.date) - new Date(a.date));
res.json({
lists: result,
total: result.length,
});
});
// Get single list with populated songs
router.get("/:id", (req, res) => {
const list = lists.get(req.params.id);
if (!list) {
return res.status(404).json({ error: "List not found" });
}
res.json({ list });
});
// Create list
router.post("/", validate(listValidation), (req, res) => {
const { name, date, songs: songIds } = req.body;
const list = {
id: uuidv4(),
name,
date: date || new Date().toISOString().split("T")[0],
songs: songIds || [],
createdBy: req.user.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
lists.set(list.id, list);
res.status(201).json({
message: "List created successfully",
list,
});
});
// Update list
router.put("/:id", validate(listValidation), (req, res) => {
const list = lists.get(req.params.id);
if (!list) {
return res.status(404).json({ error: "List not found" });
}
const { name, date, songs: songIds } = req.body;
const updatedList = {
...list,
name: name || list.name,
date: date || list.date,
songs: songIds !== undefined ? songIds : list.songs,
updatedAt: new Date().toISOString(),
};
lists.set(list.id, updatedList);
res.json({
message: "List updated successfully",
list: updatedList,
});
});
// Delete list
router.delete("/:id", (req, res) => {
const list = lists.get(req.params.id);
if (!list) {
return res.status(404).json({ error: "List not found" });
}
lists.delete(req.params.id);
res.json({ message: "List deleted successfully" });
});
// Add song to list
router.post("/:id/songs", (req, res) => {
const list = lists.get(req.params.id);
if (!list) {
return res.status(404).json({ error: "List not found" });
}
const { songId, position } = req.body;
if (!songId) {
return res.status(400).json({ error: "Song ID required" });
}
const songs = [...list.songs];
if (position !== undefined && position >= 0 && position <= songs.length) {
songs.splice(position, 0, songId);
} else {
songs.push(songId);
}
list.songs = songs;
list.updatedAt = new Date().toISOString();
lists.set(list.id, list);
res.json({
message: "Song added to list",
list,
});
});
// Remove song from list
router.delete("/:id/songs/:songId", (req, res) => {
const list = lists.get(req.params.id);
if (!list) {
return res.status(404).json({ error: "List not found" });
}
const songIndex = list.songs.indexOf(req.params.songId);
if (songIndex === -1) {
return res.status(404).json({ error: "Song not in list" });
}
list.songs.splice(songIndex, 1);
list.updatedAt = new Date().toISOString();
lists.set(list.id, list);
res.json({
message: "Song removed from list",
list,
});
});
// Reorder songs in list
router.put("/:id/reorder", (req, res) => {
const list = lists.get(req.params.id);
if (!list) {
return res.status(404).json({ error: "List not found" });
}
const { songs: newOrder } = req.body;
if (!Array.isArray(newOrder)) {
return res.status(400).json({ error: "Songs array required" });
}
list.songs = newOrder;
list.updatedAt = new Date().toISOString();
lists.set(list.id, list);
res.json({
message: "List reordered successfully",
list,
});
});
// Duplicate list
router.post("/:id/duplicate", (req, res) => {
const list = lists.get(req.params.id);
if (!list) {
return res.status(404).json({ error: "List not found" });
}
const duplicateList = {
...list,
id: uuidv4(),
name: `${list.name} (Copy)`,
songs: [...list.songs],
createdBy: req.user.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
lists.set(duplicateList.id, duplicateList);
res.status(201).json({
message: "List duplicated successfully",
list: duplicateList,
});
});
export default router;

View File

@@ -0,0 +1,128 @@
import express from "express";
import { v4 as uuidv4 } from "uuid";
const router = express.Router();
// In-memory store (replace with database in production)
const profiles = new Map([
[
"1",
{
id: "1",
name: "Pastor John",
role: "admin",
avatar: "👨‍💼",
createdAt: new Date().toISOString(),
},
],
[
"2",
{
id: "2",
name: "Sarah Miller",
role: "leader",
avatar: "👩‍🎤",
createdAt: new Date().toISOString(),
},
],
[
"3",
{
id: "3",
name: "Mike Johnson",
role: "tech",
avatar: "🎛️",
createdAt: new Date().toISOString(),
},
],
]);
// Get all profiles
router.get("/", (req, res) => {
const result = Array.from(profiles.values());
res.json({
profiles: result,
total: result.length,
});
});
// Get single profile
router.get("/:id", (req, res) => {
const profile = profiles.get(req.params.id);
if (!profile) {
return res.status(404).json({ error: "Profile not found" });
}
res.json({ profile });
});
// Create profile
router.post("/", (req, res) => {
const { name, role, avatar } = req.body;
if (!name) {
return res.status(400).json({ error: "Name is required" });
}
const profile = {
id: uuidv4(),
name,
role: role || "volunteer",
avatar: avatar || "👤",
createdAt: new Date().toISOString(),
};
profiles.set(profile.id, profile);
res.status(201).json({
message: "Profile created successfully",
profile,
});
});
// Update profile
router.put("/:id", (req, res) => {
const profile = profiles.get(req.params.id);
if (!profile) {
return res.status(404).json({ error: "Profile not found" });
}
const { name, role, avatar } = req.body;
const updatedProfile = {
...profile,
name: name || profile.name,
role: role || profile.role,
avatar: avatar || profile.avatar,
};
profiles.set(profile.id, updatedProfile);
res.json({
message: "Profile updated successfully",
profile: updatedProfile,
});
});
// Delete profile
router.delete("/:id", (req, res) => {
const profile = profiles.get(req.params.id);
if (!profile) {
return res.status(404).json({ error: "Profile not found" });
}
// Don't allow deleting admin profiles
if (profile.role === "admin") {
return res.status(403).json({ error: "Cannot delete admin profile" });
}
profiles.delete(req.params.id);
res.json({ message: "Profile deleted successfully" });
});
export default router;

View File

@@ -0,0 +1,250 @@
import express from "express";
import { v4 as uuidv4 } from "uuid";
import { validate, songValidation } from "../middleware/validate.js";
const router = express.Router();
// In-memory store (replace with database in production)
const songs = new Map([
[
"1",
{
id: "1",
title: "Amazing Grace",
artist: "John Newton",
key: "G",
originalKey: "G",
tempo: 72,
category: "Hymn",
lyrics: `[Verse 1]
[G]Amazing [D]grace, how [G]sweet the [G7]sound
That [C]saved a [G]wretch like [Em]me
I [G]once was [D]lost, but [G]now am [Em]found
Was [G]blind but [D]now I [G]see`,
createdBy: "system",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
[
"2",
{
id: "2",
title: "How Great Is Our God",
artist: "Chris Tomlin",
key: "C",
originalKey: "C",
tempo: 78,
category: "Contemporary",
lyrics: `[Verse 1]
The [C]splendor of the [Am]King
[F]Clothed in majesty [C]
Let all the earth re[Am]joice
[F]All the earth re[G]joice`,
createdBy: "system",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
]);
// Search songs (for worship list)
router.get("/search", (req, res) => {
const { q } = req.query;
if (!q) {
return res.json({
success: true,
songs: [],
total: 0,
});
}
const searchLower = q.toLowerCase();
const result = Array.from(songs.values()).filter(
(song) =>
song.title.toLowerCase().includes(searchLower) ||
song.artist.toLowerCase().includes(searchLower),
);
res.json({
success: true,
songs: result,
total: result.length,
});
});
// Get all songs
router.get("/", (req, res) => {
const { search, key, category, sort } = req.query;
let result = Array.from(songs.values());
// Search filter
if (search) {
const searchLower = search.toLowerCase();
result = result.filter(
(song) =>
song.title.toLowerCase().includes(searchLower) ||
song.artist.toLowerCase().includes(searchLower),
);
}
// Key filter
if (key) {
result = result.filter((song) => song.key === key);
}
// Category filter
if (category) {
result = result.filter((song) => song.category === category);
}
// Sort
if (sort) {
const [field, order] = sort.split(":");
result.sort((a, b) => {
const aVal = a[field] || "";
const bVal = b[field] || "";
const comparison =
typeof aVal === "string" ? aVal.localeCompare(bVal) : aVal - bVal;
return order === "desc" ? -comparison : comparison;
});
}
res.json({
songs: result,
total: result.length,
});
});
// Get single song
router.get("/:id", (req, res) => {
const song = songs.get(req.params.id);
if (!song) {
return res.status(404).json({ error: "Song not found" });
}
res.json({ song });
});
// Create song
router.post("/", validate(songValidation), (req, res) => {
const { title, artist, key, tempo, category, lyrics } = req.body;
const song = {
id: uuidv4(),
title,
artist: artist || "Unknown",
key: key || "C",
originalKey: key || "C",
tempo: tempo || 72,
category: category || "Contemporary",
lyrics: lyrics || "",
createdBy: req.user.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
songs.set(song.id, song);
res.status(201).json({
message: "Song created successfully",
song,
});
});
// Update song
router.put("/:id", validate(songValidation), (req, res) => {
const song = songs.get(req.params.id);
if (!song) {
return res.status(404).json({ error: "Song not found" });
}
const { title, artist, key, tempo, category, lyrics } = req.body;
const updatedSong = {
...song,
title: title || song.title,
artist: artist || song.artist,
key: key || song.key,
tempo: tempo || song.tempo,
category: category || song.category,
lyrics: lyrics !== undefined ? lyrics : song.lyrics,
updatedAt: new Date().toISOString(),
};
songs.set(song.id, updatedSong);
res.json({
message: "Song updated successfully",
song: updatedSong,
});
});
// Delete song
router.delete("/:id", (req, res) => {
const song = songs.get(req.params.id);
if (!song) {
return res.status(404).json({ error: "Song not found" });
}
songs.delete(req.params.id);
res.json({ message: "Song deleted successfully" });
});
// Duplicate song
router.post("/:id/duplicate", (req, res) => {
const song = songs.get(req.params.id);
if (!song) {
return res.status(404).json({ error: "Song not found" });
}
const duplicateSong = {
...song,
id: uuidv4(),
title: `${song.title} (Copy)`,
createdBy: req.user.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
songs.set(duplicateSong.id, duplicateSong);
res.status(201).json({
message: "Song duplicated successfully",
song: duplicateSong,
});
});
// Transpose song
router.post("/:id/transpose", (req, res) => {
const song = songs.get(req.params.id);
if (!song) {
return res.status(404).json({ error: "Song not found" });
}
const { targetKey, useFlats } = req.body;
// Transposition logic would go here
// For now, just return the song with updated key
const transposedSong = {
...song,
key: targetKey || song.key,
// In a real implementation, lyrics would be transposed
};
res.json({
message: "Song transposed",
song: transposedSong,
});
});
export default router;

View File

@@ -0,0 +1,258 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^W WRAP search if no match found.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n -N .... --line-numbers --LINE-NUMBERS
Don't use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--incsearch
Search file as each pattern character is typed in.
--line-num-width=N
Set the width of the -N line number field to N characters.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--rscroll=C
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--status-col-width=N
Set the width of the -J status column to N characters.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=N
Each click of the mouse wheel moves N lines.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.

50
new-site/backend/db.js Normal file
View File

@@ -0,0 +1,50 @@
const { Pool } = require("pg");
require("dotenv").config();
// Parse PostgreSQL URI
const connectionString =
process.env.POSTGRESQL_URI ||
"postgresql://songlyric_user:MySecurePass123@192.168.10.130:5432/church_songlyric";
const pool = new Pool({
connectionString,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
// Test connection on startup
pool
.connect()
.then((client) => {
console.log("✅ Connected to PostgreSQL database");
client.release();
})
.catch((err) => {
console.error("❌ Failed to connect to PostgreSQL:", err.message);
});
// Query helper
const query = async (text, params) => {
const start = Date.now();
try {
const res = await pool.query(text, params);
const duration = Date.now() - start;
if (duration > 100) {
console.log("Slow query:", {
text: text.substring(0, 100),
duration,
rows: res.rowCount,
});
}
return res;
} catch (err) {
console.error("Query error:", err.message);
throw err;
}
};
module.exports = {
pool,
query,
};

View File

@@ -0,0 +1,38 @@
const bcrypt = require("bcrypt");
const { query } = require("./db");
const users = [
{ username: "hop", password: "hopmusic2025" },
{ username: "kristen", password: "kristen2025" },
{ username: "camilah", password: "camilah2025" },
{ username: "worship-leader", password: "worship2025" },
];
async function updatePasswords() {
console.log("🔐 Updating user passwords with bcrypt hashes...\n");
for (const user of users) {
try {
// Generate bcrypt hash
const hash = await bcrypt.hash(user.password, 10);
// Update in database
await query(
"UPDATE users SET password_hash = $1 WHERE LOWER(username) = LOWER($2)",
[hash, user.username],
);
console.log(`✅ Updated password for: ${user.username}`);
} catch (err) {
console.error(`❌ Failed to update ${user.username}:`, err.message);
}
}
console.log("\n✨ Password update complete!");
process.exit(0);
}
updatePasswords().catch((err) => {
console.error("Error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,52 @@
const jwt = require("jsonwebtoken");
const authenticate = (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
const token = authHeader.split(" ")[1];
const decoded = jwt.verify(
token,
process.env.JWT_SECRET || "your-super-secret-jwt-key",
);
req.user = decoded;
next();
} catch (error) {
if (error.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expired" });
}
return res.status(401).json({ error: "Invalid token" });
}
};
const authorize = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: "Not authorized" });
}
next();
};
};
const isAdmin = (req, res, next) => {
if (!req.user || req.user.role !== "admin") {
return res.status(403).json({ error: "Admin access required" });
}
next();
};
module.exports = {
authenticate,
authorize,
isAdmin,
};

View File

@@ -0,0 +1,258 @@
/**
* Response Cache Middleware for Express
*
* Provides in-memory caching for API responses to reduce database load
* and improve response times. Especially useful for frequently accessed
* data like songs, lists, and stats.
*
* Features:
* - Configurable TTL per route
* - Cache invalidation on mutations
* - ETag support for conditional requests
* - Memory-efficient with automatic cleanup
*/
const crypto = require("crypto");
// Cache storage
const cache = new Map();
// Default TTL values (in seconds)
const DEFAULT_TTL = {
"/api/songs": 300, // 5 minutes for song list
"/api/lists": 120, // 2 minutes for worship lists
"/api/profiles": 300, // 5 minutes for profiles
"/api/stats": 60, // 1 minute for stats
default: 60, // 1 minute default
};
// Cache entry structure: { data, etag, timestamp, ttl }
/**
* Generate a cache key from request
*/
function generateCacheKey(req) {
const baseKey = `${req.method}:${req.originalUrl}`;
// Include query parameters in key
return baseKey;
}
/**
* Generate ETag from response data
*/
function generateETag(data) {
return crypto.createHash("md5").update(JSON.stringify(data)).digest("hex");
}
/**
* Check if cache entry is still valid
*/
function isCacheValid(entry) {
if (!entry) return false;
const now = Date.now();
return now - entry.timestamp < entry.ttl * 1000;
}
/**
* Get TTL for a specific route
*/
function getTTL(path) {
// Strip query params for TTL lookup
const basePath = path.split("?")[0];
// Check for exact match
if (DEFAULT_TTL[basePath]) {
return DEFAULT_TTL[basePath];
}
// Check for prefix match
for (const [key, ttl] of Object.entries(DEFAULT_TTL)) {
if (basePath.startsWith(key)) {
return ttl;
}
}
return DEFAULT_TTL.default;
}
/**
* Cache middleware - only caches GET requests
*/
function cacheMiddleware(options = {}) {
return (req, res, next) => {
// Only cache GET requests
if (req.method !== "GET") {
return next();
}
// Skip caching for authenticated user-specific data
const skipPaths = ["/api/auth/me", "/api/admin/"];
if (skipPaths.some((path) => req.originalUrl.startsWith(path))) {
return next();
}
const cacheKey = generateCacheKey(req);
const cachedEntry = cache.get(cacheKey);
// Check if we have valid cached data
if (isCacheValid(cachedEntry)) {
// Check If-None-Match header for conditional request
const clientETag = req.headers["if-none-match"];
if (clientETag && clientETag === cachedEntry.etag) {
return res.status(304).end(); // Not Modified
}
// Return cached response
res.set("X-Cache", "HIT");
res.set("ETag", cachedEntry.etag);
res.set(
"Cache-Control",
`private, max-age=${Math.floor((cachedEntry.ttl * 1000 - (Date.now() - cachedEntry.timestamp)) / 1000)}`,
);
return res.json(cachedEntry.data);
}
// Cache miss - capture the response
const originalJson = res.json.bind(res);
res.json = (data) => {
// Only cache successful responses
if (res.statusCode >= 200 && res.statusCode < 300) {
const ttl = options.ttl || getTTL(req.originalUrl);
const etag = generateETag(data);
cache.set(cacheKey, {
data,
etag,
timestamp: Date.now(),
ttl,
});
res.set("X-Cache", "MISS");
res.set("ETag", etag);
res.set("Cache-Control", `private, max-age=${ttl}`);
}
return originalJson(data);
};
next();
};
}
/**
* Invalidate cache entries matching a pattern
*/
function invalidateCache(pattern) {
if (typeof pattern === "string") {
// Invalidate specific key
cache.delete(pattern);
// Also invalidate any keys that start with pattern
for (const key of cache.keys()) {
if (key.includes(pattern)) {
cache.delete(key);
}
}
} else if (pattern instanceof RegExp) {
// Invalidate matching pattern
for (const key of cache.keys()) {
if (pattern.test(key)) {
cache.delete(key);
}
}
}
}
/**
* Invalidation middleware for mutations (POST, PUT, DELETE)
*/
function invalidationMiddleware(req, res, next) {
// Skip for GET requests
if (req.method === "GET") {
return next();
}
const originalJson = res.json.bind(res);
res.json = (data) => {
// Invalidate related caches on successful mutations
if (res.statusCode >= 200 && res.statusCode < 300) {
const basePath = req.baseUrl || "";
// Invalidate caches based on route
if (basePath.includes("/songs") || req.originalUrl.includes("/songs")) {
invalidateCache("/api/songs");
invalidateCache("/api/stats");
}
if (basePath.includes("/lists") || req.originalUrl.includes("/lists")) {
invalidateCache("/api/lists");
invalidateCache("/api/stats");
}
if (
basePath.includes("/profiles") ||
req.originalUrl.includes("/profiles")
) {
invalidateCache("/api/profiles");
invalidateCache("/api/stats");
}
}
return originalJson(data);
};
next();
}
/**
* Clear all cache entries
*/
function clearCache() {
cache.clear();
}
/**
* Get cache statistics
*/
function getCacheStats() {
let validEntries = 0;
let expiredEntries = 0;
for (const entry of cache.values()) {
if (isCacheValid(entry)) {
validEntries++;
} else {
expiredEntries++;
}
}
return {
totalEntries: cache.size,
validEntries,
expiredEntries,
keys: Array.from(cache.keys()),
};
}
/**
* Periodic cache cleanup (remove expired entries)
*/
function startCacheCleanup(intervalMs = 60000) {
setInterval(() => {
const now = Date.now();
for (const [key, entry] of cache.entries()) {
if (!isCacheValid(entry)) {
cache.delete(key);
}
}
}, intervalMs);
}
module.exports = {
cacheMiddleware,
invalidationMiddleware,
invalidateCache,
clearCache,
getCacheStats,
startCacheCleanup,
};

View File

@@ -0,0 +1,41 @@
export const notFound = (req, res, next) => {
res.status(404).json({
error: "Not Found",
message: `Cannot ${req.method} ${req.originalUrl}`,
});
};
export const errorHandler = (err, req, res, next) => {
console.error("Error:", err);
// Handle validation errors
if (err.name === "ValidationError") {
return res.status(400).json({
error: "Validation Error",
details: err.errors,
});
}
// Handle duplicate key errors
if (err.code === 11000) {
return res.status(400).json({
error: "Duplicate Entry",
message: "A record with this value already exists",
});
}
// Handle JWT errors
if (err.name === "JsonWebTokenError") {
return res.status(401).json({
error: "Invalid Token",
message: "Your session is invalid",
});
}
// Default error
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: err.message || "Internal Server Error",
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
});
};

View File

@@ -0,0 +1,79 @@
import { body, validationResult } from "express-validator";
export const validate = (validations) => {
return async (req, res, next) => {
await Promise.all(validations.map((validation) => validation.run(req)));
const errors = validationResult(req);
if (errors.isEmpty()) {
return next();
}
res.status(400).json({
error: "Validation Error",
details: errors.array().map((err) => ({
field: err.path,
message: err.msg,
})),
});
};
};
// Auth validations
export const loginValidation = [
body("email").isEmail().normalizeEmail().withMessage("Valid email required"),
body("password")
.isLength({ min: 6 })
.withMessage("Password must be at least 6 characters"),
];
export const registerValidation = [
body("name")
.trim()
.isLength({ min: 2 })
.withMessage("Name must be at least 2 characters"),
body("email").isEmail().normalizeEmail().withMessage("Valid email required"),
body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters")
.matches(/\d/)
.withMessage("Password must contain at least one number"),
];
// Song validations
export const songValidation = [
body("title").trim().notEmpty().withMessage("Title is required"),
body("key")
.optional()
.isIn([
"C",
"C#",
"Db",
"D",
"D#",
"Eb",
"E",
"F",
"F#",
"Gb",
"G",
"G#",
"Ab",
"A",
"A#",
"Bb",
"B",
]),
body("tempo")
.optional()
.isInt({ min: 40, max: 220 })
.withMessage("Tempo must be between 40 and 220"),
body("lyrics").optional().isString(),
];
// List validations
export const listValidation = [
body("name").trim().notEmpty().withMessage("Name is required"),
body("date").optional().isISO8601().withMessage("Valid date required"),
body("songs").optional().isArray(),
];

View File

@@ -0,0 +1,31 @@
-- =====================================================
-- Migration: Add Biometric Authentication Support
-- Description: Adds columns for WebAuthn biometric auth
-- Date: January 2026
-- =====================================================
-- Add biometric authentication columns to users table
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='biometric_enabled') THEN
ALTER TABLE users ADD COLUMN biometric_enabled BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='biometric_credential_id') THEN
ALTER TABLE users ADD COLUMN biometric_credential_id TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='biometric_public_key') THEN
ALTER TABLE users ADD COLUMN biometric_public_key TEXT;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='biometric_registered_at') THEN
ALTER TABLE users ADD COLUMN biometric_registered_at TIMESTAMP;
END IF;
END $$;
-- Create index for faster biometric lookups
CREATE INDEX IF NOT EXISTS idx_users_biometric_credential
ON users(biometric_credential_id)
WHERE biometric_enabled = TRUE;
-- Update existing users to have biometric_enabled = false
UPDATE users SET biometric_enabled = FALSE WHERE biometric_enabled IS NULL;

View File

@@ -0,0 +1,258 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^W WRAP search if no match found.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n -N .... --line-numbers --LINE-NUMBERS
Don't use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--incsearch
Search file as each pattern character is typed in.
--line-num-width=N
Set the width of the -N line number field to N characters.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--rscroll=C
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--status-col-width=N
Set the width of the -J status column to N characters.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=N
Each click of the mouse wheel moves N lines.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.

2348
new-site/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "worship-platform-backend",
"version": "1.0.0",
"description": "Backend API for Worship Platform",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest",
"lint": "eslint ."
},
"dependencies": {
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.0.2",
"pg": "^8.17.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.0.3"
}
}

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;

View File

@@ -0,0 +1,24 @@
Table "public.songs"
Column | Type | Collation | Nullable | Default
------------+------------------------+-----------+----------+-----------------------
id | character varying(255) | | not null |
title | character varying(500) | | not null |
artist | character varying(500) | | | ''::character varying
band | character varying(500) | | | ''::character varying
lyrics | text | | | ''::text
chords | text | | | ''::text
singer | character varying(500) | | | ''::character varying
memo | text | | | ''::text
created_at | bigint | | |
updated_at | bigint | | |
Indexes:
"songs_pkey" PRIMARY KEY, btree (id)
"idx_song_artist" btree (artist)
"idx_song_band" btree (band)
"idx_song_singer" btree (singer)
"idx_song_title" btree (title)
Referenced by:
TABLE "plan_songs" CONSTRAINT "plan_songs_song_id_fkey" FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
TABLE "profile_song_keys" CONSTRAINT "profile_song_keys_song_id_fkey" FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
TABLE "profile_songs" CONSTRAINT "profile_songs_song_id_fkey" FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE

View File

@@ -0,0 +1,24 @@
Table "public.songs"
Column | Type | Collation | Nullable | Default
------------+------------------------+-----------+----------+-----------------------
id | character varying(255) | | not null |
title | character varying(500) | | not null |
artist | character varying(500) | | | ''::character varying
band | character varying(500) | | | ''::character varying
lyrics | text | | | ''::text
chords | text | | | ''::text
singer | character varying(500) | | | ''::character varying
memo | text | | | ''::text
created_at | bigint | | |
updated_at | bigint | | |
Indexes:
"songs_pkey" PRIMARY KEY, btree (id)
"idx_song_artist" btree (artist)
"idx_song_band" btree (band)
"idx_song_singer" btree (singer)
"idx_song_title" btree (title)
Referenced by:
TABLE "plan_songs" CONSTRAINT "plan_songs_song_id_fkey" FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
TABLE "profile_song_keys" CONSTRAINT "profile_song_keys_song_id_fkey" FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
TABLE "profile_songs" CONSTRAINT "profile_songs_song_id_fkey" FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE

149
new-site/backend/server.js Normal file
View File

@@ -0,0 +1,149 @@
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const rateLimit = require("express-rate-limit");
const { query } = require("./db");
const {
cacheMiddleware,
invalidationMiddleware,
getCacheStats,
startCacheCleanup,
} = require("./middleware/cache");
// Import routes
const authRoutes = require("./routes/auth");
const songsRoutes = require("./routes/songs");
const listsRoutes = require("./routes/lists");
const profilesRoutes = require("./routes/profiles");
const adminRoutes = require("./routes/admin");
const app = express();
const PORT = process.env.PORT || 8080;
// Start cache cleanup (every 60 seconds)
startCacheCleanup(60000);
// Security middleware
app.use(
helmet({
contentSecurityPolicy: false, // Disable for development
}),
);
// CORS configuration
const allowedOrigins = [
"http://localhost:5100",
"http://localhost:3000",
"https://houseofprayer.ddns.net",
"http://houseofprayer.ddns.net",
];
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
// For development, allow all origins
callback(null, true);
}
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "If-None-Match"],
exposedHeaders: ["ETag", "X-Cache", "Cache-Control"],
}),
);
// Explicit OPTIONS handler for preflight requests
app.options("*", cors());
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // Generous limit for development
message: { error: "Too many requests, please try again later." },
});
app.use("/api/", limiter);
// Body parsing
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// Response caching middleware (applies to GET requests)
app.use("/api/", cacheMiddleware());
// Cache invalidation middleware (handles POST, PUT, DELETE)
app.use("/api/", invalidationMiddleware);
// Logging (compact format for production-like environment)
app.use(morgan("dev"));
// Health check
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Cache stats endpoint for monitoring
app.get("/api/cache-stats", (req, res) => {
res.json({
success: true,
cache: getCacheStats(),
timestamp: new Date().toISOString(),
});
});
// Stats endpoint for dashboard
app.get("/api/stats", async (req, res) => {
try {
const [songsResult, profilesResult, listsResult] = await Promise.all([
query("SELECT COUNT(*) as count FROM songs"),
query("SELECT COUNT(*) as count FROM profiles"),
query("SELECT COUNT(*) as count FROM plans"),
]);
res.json({
success: true,
stats: {
songs: parseInt(songsResult.rows[0].count),
profiles: parseInt(profilesResult.rows[0].count),
lists: parseInt(listsResult.rows[0].count),
},
});
} catch (err) {
console.error("Stats error:", err);
res.status(500).json({ success: false, message: "Failed to fetch stats" });
}
});
// API Routes
app.use("/api/auth", authRoutes);
app.use("/api/songs", songsRoutes);
app.use("/api/lists", listsRoutes);
app.use("/api/profiles", profilesRoutes);
app.use("/api/admin", adminRoutes);
// 404 handler for API routes
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
// Error handler
app.use((err, req, res, next) => {
console.error("Server error:", err);
res.status(500).json({ error: "Internal server error" });
});
// Start server
app.listen(PORT, () => {
console.log("🚀 Server running on http://localhost:" + PORT);
console.log("📊 Health check: http://localhost:" + PORT + "/health");
});
module.exports = app;

View File

@@ -0,0 +1,33 @@
// Quick test to verify auth middleware loading
const fs = require("fs");
const path = require("path");
let output = "";
function log(msg) {
output += msg + "\n";
console.log(msg);
}
try {
log("Testing auth middleware...");
const auth = require("./middleware/auth");
log("Auth exports: " + Object.keys(auth).join(", "));
log("authenticate type: " + typeof auth.authenticate);
const lists = require("./routes/lists");
log("Lists routes loaded: " + (lists ? "YES" : "NO"));
log("");
log("✅ All modules load correctly!");
log("");
log("If you are still getting 403, the backend service needs restart:");
log(" sudo systemctl restart church-music-backend.service");
} catch (err) {
log("❌ ERROR: " + err.message);
log(err.stack);
}
// Write to file
fs.writeFileSync(path.join(__dirname, "test-auth-result.txt"), output);

View File

@@ -0,0 +1,112 @@
// Direct backend test - bypassing Nginx
const http = require("http");
const testDirectBackend = async () => {
console.log("Testing backend directly on localhost:8080...\n");
// Step 1: Login
const token = await login();
if (!token) {
console.log("❌ Failed to login");
return;
}
console.log("✅ Got token:", token.substring(0, 40) + "...\n");
// Step 2: Test DELETE directly on backend
await testDelete(token);
};
function login() {
return new Promise((resolve) => {
const postData = JSON.stringify({
username: "hop",
password: "hopWorship2024",
});
const options = {
hostname: "localhost",
port: 8080,
path: "/api/auth/login",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": postData.length,
},
};
const req = http.request(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
console.log("Login response status:", res.statusCode);
try {
const json = JSON.parse(data);
resolve(json.token || null);
} catch (e) {
console.log("Login response:", data);
resolve(null);
}
});
});
req.on("error", (e) => {
console.error("Login error:", e.message);
resolve(null);
});
req.write(postData);
req.end();
});
}
function testDelete(token) {
return new Promise((resolve) => {
const listId = "24474ea3-6f34-4704-ac48-a80e1225d79e";
const songId = "9831e027-aeb1-48a0-8763-fd3120f29692";
const options = {
hostname: "localhost",
port: 8080,
path: `/api/lists/${listId}/songs/${songId}`,
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
};
console.log("Testing DELETE:", options.path);
const req = http.request(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
console.log("\n=== RESULT ===");
console.log("Status:", res.statusCode);
console.log("Response:", data);
if (res.statusCode === 200) {
console.log("\n✅ SUCCESS! Backend DELETE works!");
console.log("If you still get 403 in browser, the issue is NGINX.");
} else if (res.statusCode === 403) {
console.log("\n❌ 403 from backend - check auth middleware");
} else if (res.statusCode === 401) {
console.log("\n⚠ 401 - Token issue");
}
resolve();
});
});
req.on("error", (e) => {
console.error("Request error:", e.message);
console.log("\n❌ Backend might not be running!");
console.log("Run: sudo systemctl restart church-music-backend.service");
resolve();
});
req.end();
});
}
testDirectBackend();

View File

@@ -0,0 +1,258 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^W WRAP search if no match found.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n -N .... --line-numbers --LINE-NUMBERS
Don't use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--incsearch
Search file as each pattern character is typed in.
--line-num-width=N
Set the width of the -N line number field to N characters.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--rscroll=C
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--status-col-width=N
Set the width of the -J status column to N characters.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=N
Each click of the mouse wheel moves N lines.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.

View File

@@ -0,0 +1,258 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^W WRAP search if no match found.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n -N .... --line-numbers --LINE-NUMBERS
Don't use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--incsearch
Search file as each pattern character is typed in.
--line-num-width=N
Set the width of the -N line number field to N characters.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--rscroll=C
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--status-col-width=N
Set the width of the -J status column to N characters.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=N
Each click of the mouse wheel moves N lines.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.

View File

@@ -0,0 +1,46 @@
/**
* SQL Query Builder Utilities
*/
/**
* Build dynamic WHERE clause from conditions
* @param {Array} conditions - Array of SQL conditions
* @returns {string} WHERE clause or empty string
*/
const buildWhereClause = (conditions) => {
return conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
};
/**
* Build LIMIT/OFFSET clause
* @param {number} limit - Max results
* @param {number} offset - Results to skip
* @param {number} paramOffset - Current parameter index
* @returns {Object} { clause, params }
*/
const buildPagination = (limit, offset, paramOffset = 1) => {
return {
clause: ` LIMIT $${paramOffset} OFFSET $${paramOffset + 1}`,
params: [parseInt(limit) || 100, parseInt(offset) || 0],
};
};
/**
* Build search condition for multiple fields
* @param {string} searchTerm - Search term
* @param {Array} fields - Fields to search in
* @param {number} paramIndex - Parameter index
* @returns {string} SQL condition
*/
const buildSearchCondition = (searchTerm, fields, paramIndex) => {
const conditions = fields.map(
(field) => `LOWER(${field}) LIKE $${paramIndex}`,
);
return `(${conditions.join(" OR ")})`;
};
module.exports = {
buildWhereClause,
buildPagination,
buildSearchCondition,
};

View File

@@ -0,0 +1,33 @@
/**
* Standardized response handlers for API routes
*/
const success = (res, data = {}, statusCode = 200) => {
res.status(statusCode).json({
success: true,
...data,
});
};
const error = (res, message, statusCode = 500, additionalData = {}) => {
res.status(statusCode).json({
success: false,
message,
...additionalData,
});
};
const notFound = (res, resource = "Resource") => {
error(res, `${resource} not found`, 404);
};
const badRequest = (res, message) => {
error(res, message, 400);
};
module.exports = {
success,
error,
notFound,
badRequest,
};

View File

@@ -0,0 +1,24 @@
Table "public.songs"
Column | Type | Collation | Nullable | Default
------------+------------------------+-----------+----------+-----------------------
id | character varying(255) | | not null |
title | character varying(500) | | not null |
artist | character varying(500) | | | ''::character varying
band | character varying(500) | | | ''::character varying
lyrics | text | | | ''::text
chords | text | | | ''::text
singer | character varying(500) | | | ''::character varying
memo | text | | | ''::text
created_at | bigint | | |
updated_at | bigint | | |
Indexes:
"songs_pkey" PRIMARY KEY, btree (id)
"idx_song_artist" btree (artist)
"idx_song_band" btree (band)
"idx_song_singer" btree (singer)
"idx_song_title" btree (title)
Referenced by:
TABLE "plan_songs" CONSTRAINT "plan_songs_song_id_fkey" FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
TABLE "profile_song_keys" CONSTRAINT "profile_song_keys_song_id_fkey" FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
TABLE "profile_songs" CONSTRAINT "profile_songs_song_id_fkey" FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE