Initial commit - Church Music Database
This commit is contained in:
27
new-site/backend/.env.example
Normal file
27
new-site/backend/.env.example
Normal 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
|
||||
309
new-site/backend/api/admin.js
Normal file
309
new-site/backend/api/admin.js
Normal 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;
|
||||
380
new-site/backend/api/auth.js
Normal file
380
new-site/backend/api/auth.js
Normal 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;
|
||||
241
new-site/backend/api/lists.js
Normal file
241
new-site/backend/api/lists.js
Normal 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;
|
||||
128
new-site/backend/api/profiles.js
Normal file
128
new-site/backend/api/profiles.js
Normal 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;
|
||||
250
new-site/backend/api/songs.js
Normal file
250
new-site/backend/api/songs.js
Normal 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;
|
||||
@@ -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
50
new-site/backend/db.js
Normal 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,
|
||||
};
|
||||
38
new-site/backend/hash_passwords.js
Normal file
38
new-site/backend/hash_passwords.js
Normal 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);
|
||||
});
|
||||
52
new-site/backend/middleware/auth.js
Normal file
52
new-site/backend/middleware/auth.js
Normal 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,
|
||||
};
|
||||
258
new-site/backend/middleware/cache.js
Normal file
258
new-site/backend/middleware/cache.js
Normal 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,
|
||||
};
|
||||
41
new-site/backend/middleware/errorHandler.js
Normal file
41
new-site/backend/middleware/errorHandler.js
Normal 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 }),
|
||||
});
|
||||
};
|
||||
79
new-site/backend/middleware/validate.js
Normal file
79
new-site/backend/middleware/validate.js
Normal 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(),
|
||||
];
|
||||
31
new-site/backend/migrations/add_biometric_auth.sql
Normal file
31
new-site/backend/migrations/add_biometric_auth.sql
Normal 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;
|
||||
|
||||
258
new-site/backend/ole.error('DB Error:', e.message);
Normal file
258
new-site/backend/ole.error('DB Error:', e.message);
Normal 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
2348
new-site/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
new-site/backend/package.json
Normal file
28
new-site/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
453
new-site/backend/routes/admin.js
Normal file
453
new-site/backend/routes/admin.js
Normal 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;
|
||||
259
new-site/backend/routes/auth.js
Normal file
259
new-site/backend/routes/auth.js
Normal 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;
|
||||
277
new-site/backend/routes/lists.js
Normal file
277
new-site/backend/routes/lists.js
Normal 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;
|
||||
251
new-site/backend/routes/profiles.js
Normal file
251
new-site/backend/routes/profiles.js
Normal 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;
|
||||
214
new-site/backend/routes/songs.js
Normal file
214
new-site/backend/routes/songs.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
149
new-site/backend/server.js
Normal 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;
|
||||
33
new-site/backend/test-auth.js
Normal file
33
new-site/backend/test-auth.js
Normal 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);
|
||||
112
new-site/backend/test-direct.js
Normal file
112
new-site/backend/test-direct.js
Normal 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();
|
||||
258
new-site/backend/udo nginx -t
Normal file
258
new-site/backend/udo nginx -t
Normal 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.
|
||||
258
new-site/backend/udo systemctl stop church-music-backend.service
Normal file
258
new-site/backend/udo systemctl stop church-music-backend.service
Normal 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.
|
||||
46
new-site/backend/utils/queryBuilder.js
Normal file
46
new-site/backend/utils/queryBuilder.js
Normal 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,
|
||||
};
|
||||
33
new-site/backend/utils/responseHandler.js
Normal file
33
new-site/backend/utils/responseHandler.js
Normal 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,
|
||||
};
|
||||
24
new-site/backend/ync function check() {
Normal file
24
new-site/backend/ync function check() {
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user