Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e45ac7c4fc |
@@ -1,34 +0,0 @@
|
|||||||
# Docker Registry Configuration
|
|
||||||
# For Docker Hub: docker.io/username or just username
|
|
||||||
# For GitHub Container Registry: ghcr.io/username
|
|
||||||
DOCKER_REGISTRY=gitea-http.taildb3494.ts.net/will
|
|
||||||
|
|
||||||
# Docker Image Tag
|
|
||||||
TAG=latest
|
|
||||||
|
|
||||||
# CouchDB Configuration
|
|
||||||
COUCHDB_URL=http://couchdb:5984
|
|
||||||
COUCHDB_DB_NAME=adopt-a-street
|
|
||||||
COUCHDB_USER=admin
|
|
||||||
COUCHDB_PASSWORD=admin
|
|
||||||
COUCHDB_SECRET=change-this-secret-string
|
|
||||||
|
|
||||||
# JWT Configuration
|
|
||||||
JWT_SECRET=change-this-jwt-secret-key
|
|
||||||
|
|
||||||
# Node Environment
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=5000
|
|
||||||
FRONTEND_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# Cloudinary Configuration (optional - for image uploads)
|
|
||||||
CLOUDINARY_CLOUD_NAME=
|
|
||||||
CLOUDINARY_API_KEY=
|
|
||||||
CLOUDINARY_API_SECRET=
|
|
||||||
|
|
||||||
# Stripe Configuration (optional - for payments)
|
|
||||||
STRIPE_SECRET_KEY=
|
|
||||||
STRIPE_PUBLISHABLE_KEY=
|
|
||||||
|
|
||||||
# OpenAI Configuration (optional - for AI features)
|
|
||||||
OPENAI_API_KEY=
|
|
||||||
+7
-1
@@ -1 +1,7 @@
|
|||||||
# No files ignored - this is an internal-only repository
|
# Kubernetes secrets
|
||||||
|
deploy/k8s/secrets.yaml
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
backend/.env
|
||||||
|
frontend/.env
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
apiVersion: argoproj.io/v1alpha1
|
|
||||||
kind: Application
|
|
||||||
metadata:
|
|
||||||
name: adopt-a-street
|
|
||||||
namespace: argocd
|
|
||||||
labels:
|
|
||||||
app: adopt-a-street
|
|
||||||
spec:
|
|
||||||
destination:
|
|
||||||
namespace: adopt-a-street
|
|
||||||
server: https://kubernetes.default.svc
|
|
||||||
project: default
|
|
||||||
source:
|
|
||||||
path: deploy/k8s
|
|
||||||
repoURL: git@gitea-gitea-ssh.taildb3494.ts.net:will/adopt-a-street.git
|
|
||||||
targetRevision: main
|
|
||||||
syncPolicy:
|
|
||||||
automated:
|
|
||||||
prune: true
|
|
||||||
selfHeal: true
|
|
||||||
allowEmpty: false
|
|
||||||
syncOptions:
|
|
||||||
- CreateNamespace=true
|
|
||||||
- ServerSideApply=true
|
|
||||||
retry:
|
|
||||||
limit: 5
|
|
||||||
backoff:
|
|
||||||
duration: 5s
|
|
||||||
factor: 2
|
|
||||||
maxDuration: 3m
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
const User = require("../models/User");
|
|
||||||
|
|
||||||
module.exports = async function (req, res, next) {
|
|
||||||
try {
|
|
||||||
const user = await User.findById(req.user.id);
|
|
||||||
|
|
||||||
if (!user || !user.isAdmin) {
|
|
||||||
return res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
msg: "Access denied. Admin privileges required."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Admin auth error:", err.message);
|
|
||||||
return res.status(500).json({ success: false, msg: "Server error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -47,9 +47,8 @@ class User {
|
|||||||
|
|
||||||
|
|
||||||
// --- Gamification & App Data ---
|
// --- Gamification & App Data ---
|
||||||
this.isPremium = data.isPremium || false;
|
this.isPremium = data.isPremium || false;
|
||||||
this.isAdmin = data.isAdmin || false;
|
this.points = Math.max(0, data.points || 0);
|
||||||
this.points = Math.max(0, data.points || 0);
|
|
||||||
this.adoptedStreets = data.adoptedStreets || [];
|
this.adoptedStreets = data.adoptedStreets || [];
|
||||||
this.completedTasks = data.completedTasks || [];
|
this.completedTasks = data.completedTasks || [];
|
||||||
this.posts = data.posts || [];
|
this.posts = data.posts || [];
|
||||||
@@ -206,11 +205,10 @@ class User {
|
|||||||
location: this.location,
|
location: this.location,
|
||||||
website: this.website,
|
website: this.website,
|
||||||
social: this.social,
|
social: this.social,
|
||||||
privacySettings: this.privacySettings,
|
privacySettings: this.privacySettings,
|
||||||
preferences: this.preferences,
|
preferences: this.preferences,
|
||||||
isPremium: this.isPremium,
|
isPremium: this.isPremium,
|
||||||
isAdmin: this.isAdmin,
|
points: this.points,
|
||||||
points: this.points,
|
|
||||||
adoptedStreets: this.adoptedStreets,
|
adoptedStreets: this.adoptedStreets,
|
||||||
completedTasks: this.completedTasks,
|
completedTasks: this.completedTasks,
|
||||||
posts: this.posts,
|
posts: this.posts,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const adminAuth = require("../middleware/adminAuth");
|
|
||||||
const { asyncHandler } = require("../middleware/errorHandler");
|
const { asyncHandler } = require("../middleware/errorHandler");
|
||||||
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
|
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
|
||||||
const couchdbService = require("../services/couchdbService");
|
const couchdbService = require("../services/couchdbService");
|
||||||
@@ -78,7 +77,6 @@ const groupByTimePeriod = (data, groupBy = "day", dateField = "createdAt") => {
|
|||||||
router.get(
|
router.get(
|
||||||
"/overview",
|
"/overview",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
getCacheMiddleware(300), // Cache for 5 minutes
|
getCacheMiddleware(300), // Cache for 5 minutes
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { timeframe = "all" } = req.query;
|
const { timeframe = "all" } = req.query;
|
||||||
@@ -251,7 +249,6 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/activity",
|
"/activity",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
getCacheMiddleware(300), // Cache for 5 minutes
|
getCacheMiddleware(300), // Cache for 5 minutes
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { timeframe = "30d", groupBy = "day" } = req.query;
|
const { timeframe = "30d", groupBy = "day" } = req.query;
|
||||||
@@ -338,7 +335,6 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/top-contributors",
|
"/top-contributors",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
getCacheMiddleware(300), // Cache for 5 minutes
|
getCacheMiddleware(300), // Cache for 5 minutes
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { limit = 10, timeframe = "all", metric = "points" } = req.query;
|
const { limit = 10, timeframe = "all", metric = "points" } = req.query;
|
||||||
@@ -476,7 +472,6 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/street-stats",
|
"/street-stats",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
getCacheMiddleware(300), // Cache for 5 minutes
|
getCacheMiddleware(300), // Cache for 5 minutes
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { timeframe = "all" } = req.query;
|
const { timeframe = "all" } = req.query;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const adminAuth = require("../middleware/adminAuth");
|
|
||||||
const { asyncHandler } = require("../middleware/errorHandler");
|
const { asyncHandler } = require("../middleware/errorHandler");
|
||||||
const { getCacheStats, clearCache } = require("../middleware/cache");
|
const { getCacheStats, clearCache } = require("../middleware/cache");
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ router.get(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/",
|
"/",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
clearCache();
|
clearCache();
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const Reward = require("../models/Reward");
|
const Reward = require("../models/Reward");
|
||||||
const auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const adminAuth = require("../middleware/adminAuth");
|
|
||||||
const { asyncHandler } = require("../middleware/errorHandler");
|
const { asyncHandler } = require("../middleware/errorHandler");
|
||||||
const {
|
const {
|
||||||
createRewardValidation,
|
createRewardValidation,
|
||||||
@@ -29,7 +28,6 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
createRewardValidation,
|
createRewardValidation,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { name, description, cost, isPremium } = req.body;
|
const { name, description, cost, isPremium } = req.body;
|
||||||
@@ -104,7 +102,6 @@ router.get(
|
|||||||
router.put(
|
router.put(
|
||||||
"/:id",
|
"/:id",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
rewardIdValidation,
|
rewardIdValidation,
|
||||||
createRewardValidation,
|
createRewardValidation,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
@@ -129,7 +126,6 @@ router.put(
|
|||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
rewardIdValidation,
|
rewardIdValidation,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const reward = await Reward.findById(req.params.id);
|
const reward = await Reward.findById(req.params.id);
|
||||||
@@ -233,7 +229,6 @@ router.get(
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/:id/toggle",
|
"/:id/toggle",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
rewardIdValidation,
|
rewardIdValidation,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const updatedReward = await Reward.toggleActiveStatus(req.params.id);
|
const updatedReward = await Reward.toggleActiveStatus(req.params.id);
|
||||||
@@ -245,7 +240,6 @@ router.patch(
|
|||||||
router.post(
|
router.post(
|
||||||
"/bulk",
|
"/bulk",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { rewards } = req.body;
|
const { rewards } = req.body;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ const Street = require("../models/Street");
|
|||||||
const User = require("../models/User");
|
const User = require("../models/User");
|
||||||
const couchdbService = require("../services/couchdbService");
|
const couchdbService = require("../services/couchdbService");
|
||||||
const auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const adminAuth = require("../middleware/adminAuth");
|
|
||||||
const { asyncHandler } = require("../middleware/errorHandler");
|
const { asyncHandler } = require("../middleware/errorHandler");
|
||||||
const {
|
const {
|
||||||
createStreetValidation,
|
createStreetValidation,
|
||||||
@@ -104,7 +103,6 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
auth,
|
auth,
|
||||||
adminAuth,
|
|
||||||
createStreetValidation,
|
createStreetValidation,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { name, location } = req.body;
|
const { name, location } = req.body;
|
||||||
|
|||||||
@@ -1,477 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
// Setup module path to include backend node_modules
|
|
||||||
const path = require('path');
|
|
||||||
const backendPath = path.join(__dirname, '..');
|
|
||||||
process.env.NODE_PATH = path.join(backendPath, 'node_modules') + ':' + (process.env.NODE_PATH || '');
|
|
||||||
require('module').Module._initPaths();
|
|
||||||
|
|
||||||
const Nano = require('nano');
|
|
||||||
// Load .env file if it exists (for local development)
|
|
||||||
const dotenvPath = path.join(backendPath, '.env');
|
|
||||||
const fs = require('fs');
|
|
||||||
if (fs.existsSync(dotenvPath)) {
|
|
||||||
require('dotenv').config({ path: dotenvPath });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const COUCHDB_URL = process.env.COUCHDB_URL || 'http://localhost:5984';
|
|
||||||
const COUCHDB_USER = process.env.COUCHDB_USER || 'admin';
|
|
||||||
const COUCHDB_PASSWORD = process.env.COUCHDB_PASSWORD || 'admin';
|
|
||||||
const COUCHDB_DB_NAME = process.env.COUCHDB_DB_NAME || 'adopt-a-street';
|
|
||||||
|
|
||||||
class CouchDBSetup {
|
|
||||||
constructor() {
|
|
||||||
this.nano = Nano({
|
|
||||||
url: COUCHDB_URL,
|
|
||||||
auth: { username: COUCHDB_USER, password: COUCHDB_PASSWORD }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
console.log('🚀 Initializing CouchDB setup...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test connection
|
|
||||||
await this.nano.info();
|
|
||||||
console.log('✅ Connected to CouchDB');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to connect to CouchDB:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createDatabase() {
|
|
||||||
console.log(`📦 Creating database: ${COUCHDB_DB_NAME}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.nano.db.create(COUCHDB_DB_NAME);
|
|
||||||
console.log('✅ Database created successfully');
|
|
||||||
} catch (error) {
|
|
||||||
if (error.statusCode === 412) {
|
|
||||||
console.log('ℹ️ Database already exists');
|
|
||||||
} else {
|
|
||||||
console.error('❌ Failed to create database:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createIndexes() {
|
|
||||||
console.log('🔍 Creating indexes...');
|
|
||||||
|
|
||||||
const db = this.nano.use(COUCHDB_DB_NAME);
|
|
||||||
|
|
||||||
// Create design document with indexes
|
|
||||||
const designDoc = {
|
|
||||||
_id: '_design/adopt-a-street',
|
|
||||||
views: {
|
|
||||||
users_by_email: {
|
|
||||||
map: "function(doc) { if (doc.type === 'user') { emit(doc.email, doc); } }"
|
|
||||||
},
|
|
||||||
streets_by_location: {
|
|
||||||
map: "function(doc) { if (doc.type === 'street') { emit(doc.location, doc); } }"
|
|
||||||
},
|
|
||||||
by_user: {
|
|
||||||
map: "function(doc) { if (doc.user && doc.user.userId) { emit(doc.user.userId, doc); } }"
|
|
||||||
},
|
|
||||||
users_by_points: {
|
|
||||||
map: "function(doc) { if (doc.type === 'user') { emit(doc.points, doc); } }"
|
|
||||||
},
|
|
||||||
posts_by_date: {
|
|
||||||
map: "function(doc) { if (doc.type === 'post') { emit(doc.createdAt, doc); } }"
|
|
||||||
},
|
|
||||||
streets_by_status: {
|
|
||||||
map: "function(doc) { if (doc.type === 'street') { emit(doc.status, doc); } }"
|
|
||||||
},
|
|
||||||
events_by_date_status: {
|
|
||||||
map: "function(doc) { if (doc.type === 'event') { emit([doc.date, doc.status], doc); } }"
|
|
||||||
},
|
|
||||||
comments_by_post: {
|
|
||||||
map: "function(doc) { if (doc.type === 'comment' && doc.post && doc.post.postId) { emit(doc.post.postId, doc); } }"
|
|
||||||
},
|
|
||||||
transactions_by_user_date: {
|
|
||||||
map: "function(doc) { if (doc.type === 'point_transaction' && doc.user && doc.user.userId) { emit([doc.user.userId, doc.createdAt], doc); } }"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
indexes: {
|
|
||||||
users_by_email: {
|
|
||||||
index: {
|
|
||||||
fields: ["type", "email"]
|
|
||||||
},
|
|
||||||
name: "user-by-email",
|
|
||||||
type: "json"
|
|
||||||
},
|
|
||||||
streets_by_location: {
|
|
||||||
index: {
|
|
||||||
fields: ["type", "location"]
|
|
||||||
},
|
|
||||||
name: "streets-by-location",
|
|
||||||
type: "json"
|
|
||||||
},
|
|
||||||
by_user: {
|
|
||||||
index: {
|
|
||||||
fields: ["type", "user.userId"]
|
|
||||||
},
|
|
||||||
name: "by-user",
|
|
||||||
type: "json"
|
|
||||||
},
|
|
||||||
users_by_points: {
|
|
||||||
index: {
|
|
||||||
fields: ["type", "points"]
|
|
||||||
},
|
|
||||||
name: "users-by-points",
|
|
||||||
type: "json"
|
|
||||||
},
|
|
||||||
posts_by_date: {
|
|
||||||
index: {
|
|
||||||
fields: ["type", "createdAt"]
|
|
||||||
},
|
|
||||||
name: "posts-by-date",
|
|
||||||
type: "json"
|
|
||||||
},
|
|
||||||
streets_by_status: {
|
|
||||||
index: {
|
|
||||||
fields: ["type", "status"]
|
|
||||||
},
|
|
||||||
name: "streets-by-status",
|
|
||||||
type: "json"
|
|
||||||
},
|
|
||||||
events_by_date_status: {
|
|
||||||
index: {
|
|
||||||
fields: ["type", "date", "status"]
|
|
||||||
},
|
|
||||||
name: "events-by-date-status",
|
|
||||||
type: "json"
|
|
||||||
},
|
|
||||||
comments_by_post: {
|
|
||||||
index: {
|
|
||||||
fields: ["type", "post.postId"]
|
|
||||||
},
|
|
||||||
name: "comments-by-post",
|
|
||||||
type: "json"
|
|
||||||
},
|
|
||||||
transactions_by_user_date: {
|
|
||||||
index: {
|
|
||||||
fields: ["type", "user.userId", "createdAt"]
|
|
||||||
},
|
|
||||||
name: "transactions-by-user-date",
|
|
||||||
type: "json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
language: "javascript"
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(designDoc);
|
|
||||||
console.log('✅ Design document and indexes created successfully');
|
|
||||||
} catch (error) {
|
|
||||||
if (error.statusCode === 409) {
|
|
||||||
// Document already exists, update it
|
|
||||||
const existing = await db.get('_design/adopt-a-street');
|
|
||||||
designDoc._rev = existing._rev;
|
|
||||||
await db.insert(designDoc);
|
|
||||||
console.log('✅ Design document and indexes updated successfully');
|
|
||||||
} else {
|
|
||||||
console.error('❌ Failed to create indexes:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSecurityDocument() {
|
|
||||||
console.log('🔒 Setting up security document...');
|
|
||||||
|
|
||||||
const db = this.nano.use(COUCHDB_DB_NAME);
|
|
||||||
|
|
||||||
const securityDoc = {
|
|
||||||
admins: {
|
|
||||||
names: [COUCHDB_USER],
|
|
||||||
roles: []
|
|
||||||
},
|
|
||||||
members: {
|
|
||||||
names: [],
|
|
||||||
roles: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(securityDoc, '_security');
|
|
||||||
console.log('✅ Security document created successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ Failed to create security document:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async seedBadges() {
|
|
||||||
console.log('🏆 Seeding badges...');
|
|
||||||
|
|
||||||
const db = this.nano.use(COUCHDB_DB_NAME);
|
|
||||||
|
|
||||||
// Check if badges already exist
|
|
||||||
try {
|
|
||||||
const existingBadges = await db.find({
|
|
||||||
selector: { type: 'badge' },
|
|
||||||
limit: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingBadges.docs.length > 0) {
|
|
||||||
console.log('ℹ️ Badges already exist, skipping seeding');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Continue with seeding
|
|
||||||
}
|
|
||||||
|
|
||||||
const badges = [
|
|
||||||
// Street Adoption Badges
|
|
||||||
{
|
|
||||||
_id: 'badge_first_adoption',
|
|
||||||
type: 'badge',
|
|
||||||
name: 'First Adoption',
|
|
||||||
description: 'Adopted your first street',
|
|
||||||
icon: '🏡',
|
|
||||||
criteria: { type: 'street_adoptions', threshold: 1 },
|
|
||||||
rarity: 'common',
|
|
||||||
order: 1,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
_id: 'badge_street_adopter',
|
|
||||||
type: 'badge',
|
|
||||||
name: 'Street Adopter',
|
|
||||||
description: 'Adopted 5 streets',
|
|
||||||
icon: '🏘️',
|
|
||||||
criteria: { type: 'street_adoptions', threshold: 5 },
|
|
||||||
rarity: 'rare',
|
|
||||||
order: 2,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
_id: 'badge_neighborhood_champion',
|
|
||||||
type: 'badge',
|
|
||||||
name: 'Neighborhood Champion',
|
|
||||||
description: 'Adopted 10 streets',
|
|
||||||
icon: '🌆',
|
|
||||||
criteria: { type: 'street_adoptions', threshold: 10 },
|
|
||||||
rarity: 'epic',
|
|
||||||
order: 3,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
_id: 'badge_city_guardian',
|
|
||||||
type: 'badge',
|
|
||||||
name: 'City Guardian',
|
|
||||||
description: 'Adopted 25 streets',
|
|
||||||
icon: '🏙️',
|
|
||||||
criteria: { type: 'street_adoptions', threshold: 25 },
|
|
||||||
rarity: 'legendary',
|
|
||||||
order: 4,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
},
|
|
||||||
// Task Completion Badges
|
|
||||||
{
|
|
||||||
_id: 'badge_first_task',
|
|
||||||
type: 'badge',
|
|
||||||
name: 'First Task',
|
|
||||||
description: 'Completed your first task',
|
|
||||||
icon: '✅',
|
|
||||||
criteria: { type: 'task_completions', threshold: 1 },
|
|
||||||
rarity: 'common',
|
|
||||||
order: 5,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
_id: 'badge_task_master',
|
|
||||||
type: 'badge',
|
|
||||||
name: 'Task Master',
|
|
||||||
description: 'Completed 10 tasks',
|
|
||||||
icon: '🎯',
|
|
||||||
criteria: { type: 'task_completions', threshold: 10 },
|
|
||||||
rarity: 'rare',
|
|
||||||
order: 6,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
_id: 'badge_dedicated_worker',
|
|
||||||
type: 'badge',
|
|
||||||
name: 'Dedicated Worker',
|
|
||||||
description: 'Completed 50 tasks',
|
|
||||||
icon: '🛠️',
|
|
||||||
criteria: { type: 'task_completions', threshold: 50 },
|
|
||||||
rarity: 'epic',
|
|
||||||
order: 7,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
_id: 'badge_maintenance_legend',
|
|
||||||
type: 'badge',
|
|
||||||
name: 'Maintenance Legend',
|
|
||||||
description: 'Completed 100 tasks',
|
|
||||||
icon: '⚡',
|
|
||||||
criteria: { type: 'task_completions', threshold: 100 },
|
|
||||||
rarity: 'legendary',
|
|
||||||
order: 8,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await db.bulk({ docs: badges });
|
|
||||||
const successCount = results.filter(r => !r.error).length;
|
|
||||||
console.log(`✅ Successfully seeded ${successCount} badges`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to seed badges:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifySetup() {
|
|
||||||
console.log('🔍 Verifying setup...');
|
|
||||||
|
|
||||||
const db = this.nano.use(COUCHDB_DB_NAME);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check database info
|
|
||||||
const info = await db.info();
|
|
||||||
console.log(`✅ Database "${info.db_name}" is ready`);
|
|
||||||
console.log(` - Doc count: ${info.doc_count}`);
|
|
||||||
console.log(` - Update seq: ${info.update_seq}`);
|
|
||||||
|
|
||||||
// Check indexes
|
|
||||||
const designDoc = await db.get('_design/adopt-a-street');
|
|
||||||
const indexCount = Object.keys(designDoc.indexes || {}).length;
|
|
||||||
console.log(`✅ ${indexCount} indexes created`);
|
|
||||||
|
|
||||||
// Check badges
|
|
||||||
const badges = await db.find({
|
|
||||||
selector: { type: 'badge' },
|
|
||||||
fields: ['name']
|
|
||||||
});
|
|
||||||
console.log(`✅ ${badges.docs.length} badges available`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Setup verification failed:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async seedAdminUser() {
|
|
||||||
console.log('👤 Seeding admin user...');
|
|
||||||
|
|
||||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
|
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
|
||||||
|
|
||||||
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
|
||||||
console.log('⚠️ ADMIN_EMAIL or ADMIN_PASSWORD not set, skipping admin user creation');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = this.nano.use(COUCHDB_DB_NAME);
|
|
||||||
|
|
||||||
// Check if admin user already exists
|
|
||||||
try {
|
|
||||||
const existing = await db.find({
|
|
||||||
selector: { type: 'user', email: ADMIN_EMAIL },
|
|
||||||
limit: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing.docs.length > 0) {
|
|
||||||
const user = existing.docs[0];
|
|
||||||
if (!user.isAdmin) {
|
|
||||||
user.isAdmin = true;
|
|
||||||
user.updatedAt = new Date().toISOString();
|
|
||||||
await db.insert(user);
|
|
||||||
console.log('✅ Existing user promoted to admin');
|
|
||||||
} else {
|
|
||||||
console.log('ℹ️ Admin user already exists');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Continue with creation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new admin user with hashed password
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const salt = await bcrypt.genSalt(10);
|
|
||||||
const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, salt);
|
|
||||||
|
|
||||||
const adminUser = {
|
|
||||||
_id: `user_admin_${Date.now()}`,
|
|
||||||
type: 'user',
|
|
||||||
name: 'Administrator',
|
|
||||||
email: ADMIN_EMAIL,
|
|
||||||
password: hashedPassword,
|
|
||||||
isAdmin: true,
|
|
||||||
isPremium: true,
|
|
||||||
points: 0,
|
|
||||||
avatar: null,
|
|
||||||
profilePicture: null,
|
|
||||||
bio: 'System Administrator',
|
|
||||||
location: '',
|
|
||||||
website: '',
|
|
||||||
social: { twitter: '', github: '', linkedin: '' },
|
|
||||||
privacySettings: { profileVisibility: 'private' },
|
|
||||||
preferences: { emailNotifications: true, pushNotifications: true, theme: 'light' },
|
|
||||||
adoptedStreets: [],
|
|
||||||
completedTasks: [],
|
|
||||||
posts: [],
|
|
||||||
events: [],
|
|
||||||
earnedBadges: [],
|
|
||||||
stats: { streetsAdopted: 0, tasksCompleted: 0, postsCreated: 0, eventsParticipated: 0, badgesEarned: 0 },
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.insert(adminUser);
|
|
||||||
console.log(`✅ Admin user created: ${ADMIN_EMAIL}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async run() {
|
|
||||||
try {
|
|
||||||
await this.initialize();
|
|
||||||
await this.createDatabase();
|
|
||||||
await this.createIndexes();
|
|
||||||
await this.createSecurityDocument();
|
|
||||||
await this.seedBadges();
|
|
||||||
await this.seedAdminUser();
|
|
||||||
await this.verifySetup();
|
|
||||||
|
|
||||||
console.log('\n🎉 CouchDB setup completed successfully!');
|
|
||||||
console.log(`\n📋 Connection Details:`);
|
|
||||||
console.log(` URL: ${COUCHDB_URL}`);
|
|
||||||
console.log(` Database: ${COUCHDB_DB_NAME}`);
|
|
||||||
console.log(` User: ${COUCHDB_USER}`);
|
|
||||||
console.log(`\n🌐 Access CouchDB at: ${COUCHDB_URL}/_utils`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n❌ Setup failed:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run setup if called directly
|
|
||||||
if (require.main === module) {
|
|
||||||
const setup = new CouchDBSetup();
|
|
||||||
setup.run().catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = CouchDBSetup;
|
|
||||||
@@ -29,6 +29,3 @@ data:
|
|||||||
# OpenAI Configuration (optional - for AI features)
|
# OpenAI Configuration (optional - for AI features)
|
||||||
# Note: OPENAI_API_KEY should be in secrets.yaml
|
# Note: OPENAI_API_KEY should be in secrets.yaml
|
||||||
OPENAI_MODEL: "gpt-3.5-turbo"
|
OPENAI_MODEL: "gpt-3.5-turbo"
|
||||||
|
|
||||||
# Admin Configuration
|
|
||||||
ADMIN_EMAIL: "will@wills-portal.com"
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: couchdb-init
|
|
||||||
labels:
|
|
||||||
app: couchdb-init
|
|
||||||
spec:
|
|
||||||
backoffLimit: 3
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: couchdb-init
|
|
||||||
spec:
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: regcred
|
|
||||||
initContainers:
|
|
||||||
- name: wait-for-couchdb
|
|
||||||
image: curlimages/curl:8.5.0
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
COUNT=0
|
|
||||||
MAX_ATTEMPTS=60
|
|
||||||
until curl -f -s http://adopt-a-street-couchdb.adopt-a-street.svc.cluster.local:5984/_up > /dev/null 2>&1; do
|
|
||||||
COUNT=$((COUNT+1))
|
|
||||||
if [ $COUNT -ge $MAX_ATTEMPTS ]; then
|
|
||||||
echo "Timeout waiting for CouchDB after $MAX_ATTEMPTS attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Waiting for CouchDB to be ready... (attempt $COUNT/$MAX_ATTEMPTS)"
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
echo "CouchDB is ready!"
|
|
||||||
containers:
|
|
||||||
- name: couchdb-init
|
|
||||||
image: gitea-http.taildb3494.ts.net/will/adopt-a-street-backend:latest
|
|
||||||
imagePullPolicy: Always
|
|
||||||
command: ["node", "scripts/setup-couchdb.js"]
|
|
||||||
envFrom:
|
|
||||||
- configMapRef:
|
|
||||||
name: adopt-a-street-config
|
|
||||||
- secretRef:
|
|
||||||
name: adopt-a-street-secrets
|
|
||||||
env:
|
|
||||||
- name: COUCHDB_USER
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adopt-a-street-secrets
|
|
||||||
key: COUCHDB_USER
|
|
||||||
- name: COUCHDB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adopt-a-street-secrets
|
|
||||||
key: COUCHDB_PASSWORD
|
|
||||||
- name: ADMIN_EMAIL
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adopt-a-street-secrets
|
|
||||||
key: ADMIN_EMAIL
|
|
||||||
- name: ADMIN_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adopt-a-street-secrets
|
|
||||||
key: ADMIN_PASSWORD
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "128Mi"
|
|
||||||
cpu: "100m"
|
|
||||||
limits:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
restartPolicy: Never
|
|
||||||
@@ -45,21 +45,6 @@ spec:
|
|||||||
operator: In
|
operator: In
|
||||||
values:
|
values:
|
||||||
- arm64 # Pi 5 architecture
|
- arm64 # Pi 5 architecture
|
||||||
initContainers:
|
|
||||||
- name: setup-config
|
|
||||||
image: busybox:1.36
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
echo "[chttpd]" > /opt/couchdb/etc/local.d/bind.ini
|
|
||||||
echo "bind_address = 0.0.0.0" >> /opt/couchdb/etc/local.d/bind.ini
|
|
||||||
cat /opt/couchdb/etc/local.d/bind.ini
|
|
||||||
volumeMounts:
|
|
||||||
- name: couchdb-data
|
|
||||||
mountPath: /opt/couchdb/data
|
|
||||||
- name: local-config
|
|
||||||
mountPath: /opt/couchdb/etc/local.d
|
|
||||||
containers:
|
containers:
|
||||||
- name: couchdb
|
- name: couchdb
|
||||||
image: couchdb:3.3
|
image: couchdb:3.3
|
||||||
@@ -86,39 +71,30 @@ spec:
|
|||||||
key: COUCHDB_SECRET
|
key: COUCHDB_SECRET
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "256Mi"
|
memory: "512Mi"
|
||||||
cpu: "100m"
|
cpu: "250m"
|
||||||
limits:
|
limits:
|
||||||
memory: "1Gi"
|
memory: "2Gi"
|
||||||
cpu: "500m"
|
cpu: "1000m"
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: couchdb-data
|
- name: couchdb-data
|
||||||
mountPath: /opt/couchdb/data
|
mountPath: /opt/couchdb/data
|
||||||
- name: local-config
|
|
||||||
mountPath: /opt/couchdb/etc/local.d
|
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
exec:
|
httpGet:
|
||||||
command:
|
path: /_up
|
||||||
- curl
|
port: 5984
|
||||||
- -f
|
|
||||||
- http://localhost:5984/_up
|
|
||||||
initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
failureThreshold: 6
|
failureThreshold: 6
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
exec:
|
httpGet:
|
||||||
command:
|
path: /_up
|
||||||
- curl
|
port: 5984
|
||||||
- -f
|
|
||||||
- http://localhost:5984/_up
|
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
failureThreshold: 6
|
failureThreshold: 6
|
||||||
volumes:
|
|
||||||
- name: local-config
|
|
||||||
emptyDir: {}
|
|
||||||
|
|
||||||
volumeClaimTemplates:
|
volumeClaimTemplates:
|
||||||
- metadata:
|
- metadata:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ metadata:
|
|||||||
name: regcred
|
name: regcred
|
||||||
type: kubernetes.io/dockerconfigjson
|
type: kubernetes.io/dockerconfigjson
|
||||||
data:
|
data:
|
||||||
.dockerconfigjson: eyJhdXRocyI6eyJnaXRlYS1naXRlYS1odHRwLnRhaWxkYjM0OTQudHMubmV0Ijp7InVzZXJuYW1lIjoid2lsbCIsInBhc3N3b3JkIjoiZnJhY2s2NjYiLCJhdXRoIjoiZDJsc2JEcm1yY2t6TjZOZz09In19fQ==
|
.dockerconfigjson: eyJhdXRocyI6eyJnaXRlYS1odHRwLnRhaWxkYjM0OTQudHMubmV0Ijp7InVzZXJuYW1lIjoid2lsbCIsInBhc3N3b3JkIjoiW1lPVVJfR0lURUFfUEFTU1dPUkRdIiwiYXV0aCI6IltBVVRIX1RPS0VOXSJ9fX0=
|
||||||
|
|
||||||
---
|
---
|
||||||
# IMPORTANT:
|
# IMPORTANT:
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
data:
|
|
||||||
ADMIN_EMAIL: d2lsbEB3aWxscy1wb3J0YWwuY29t
|
|
||||||
ADMIN_PASSWORD: ZnJhY2s2NjY=
|
|
||||||
CLOUDINARY_API_KEY: ""
|
|
||||||
CLOUDINARY_API_SECRET: ""
|
|
||||||
CLOUDINARY_CLOUD_NAME: ""
|
|
||||||
COUCHDB_PASSWORD: c2VjcmV0X3Bhc3N3b3Jk
|
|
||||||
COUCHDB_SECRET: c2VjcmV0X2Nvb2tpZQ==
|
|
||||||
COUCHDB_USER: YWRtaW4=
|
|
||||||
JWT_SECRET: bkxOZWtJSUhiR0M3RHQ3eWMwMExWT2xNS2ZHWThNS0lHMjV4aHdEUXp5b3MzMExBZk1vZVpTeHd3dmZxdGtaUw==
|
|
||||||
OPENAI_API_KEY: ""
|
|
||||||
STRIPE_PUBLISHABLE_KEY: ""
|
|
||||||
STRIPE_SECRET_KEY: ""
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: adopt-a-street-secrets
|
|
||||||
@@ -22,10 +22,6 @@ stringData:
|
|||||||
# OpenAI Configuration (optional - for AI features)
|
# OpenAI Configuration (optional - for AI features)
|
||||||
OPENAI_API_KEY: "your-openai-api-key"
|
OPENAI_API_KEY: "your-openai-api-key"
|
||||||
|
|
||||||
# Admin User Configuration - CHANGE THESE IN PRODUCTION!
|
|
||||||
ADMIN_EMAIL: "admin@example.com" # Default admin user email
|
|
||||||
ADMIN_PASSWORD: "change-this-password" # Default admin user password
|
|
||||||
|
|
||||||
---
|
---
|
||||||
# IMPORTANT:
|
# IMPORTANT:
|
||||||
# 1. Copy this file to secrets.yaml
|
# 1. Copy this file to secrets.yaml
|
||||||
@@ -35,4 +31,3 @@ stringData:
|
|||||||
# 5. Generate strong passwords for CouchDB using: openssl rand -base64 32
|
# 5. Generate strong passwords for CouchDB using: openssl rand -base64 32
|
||||||
# 6. Non-sensitive config values (CLOUDINARY_CLOUD_NAME, STRIPE_PUBLISHABLE_KEY, OPENAI_MODEL)
|
# 6. Non-sensitive config values (CLOUDINARY_CLOUD_NAME, STRIPE_PUBLISHABLE_KEY, OPENAI_MODEL)
|
||||||
# are in configmap.yaml
|
# are in configmap.yaml
|
||||||
# 7. Set ADMIN_EMAIL and ADMIN_PASSWORD to create the default admin user at deployment
|
|
||||||
|
|||||||
+2
-5
@@ -20,8 +20,6 @@ import Premium from "./components/Premium";
|
|||||||
import Analytics from "./components/Analytics";
|
import Analytics from "./components/Analytics";
|
||||||
import Navbar from "./components/Navbar";
|
import Navbar from "./components/Navbar";
|
||||||
import PrivateRoute from "./components/PrivateRoute";
|
import PrivateRoute from "./components/PrivateRoute";
|
||||||
import AdminRoute from "./components/AdminRoute";
|
|
||||||
import AdminDashboard from "./components/AdminDashboard";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -42,9 +40,8 @@ function App() {
|
|||||||
<Route path="/rewards" element={<PrivateRoute><Rewards /></PrivateRoute>} />
|
<Route path="/rewards" element={<PrivateRoute><Rewards /></PrivateRoute>} />
|
||||||
<Route path="/leaderboard" element={<PrivateRoute><Leaderboard /></PrivateRoute>} />
|
<Route path="/leaderboard" element={<PrivateRoute><Leaderboard /></PrivateRoute>} />
|
||||||
<Route path="/premium" element={<PrivateRoute><Premium /></PrivateRoute>} />
|
<Route path="/premium" element={<PrivateRoute><Premium /></PrivateRoute>} />
|
||||||
<Route path="/analytics" element={<PrivateRoute><Analytics /></PrivateRoute>} />
|
<Route path="/analytics" element={<PrivateRoute><Analytics /></PrivateRoute>} />
|
||||||
<Route path="/admin/*" element={<AdminRoute><AdminDashboard /></AdminRoute>} />
|
<Route path="/" element={<Navigate to="/map" replace />} />
|
||||||
<Route path="/" element={<Navigate to="/map" replace />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
<ToastContainer
|
<ToastContainer
|
||||||
|
|||||||
@@ -1,909 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import axios from "axios";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
|
|
||||||
const AdminDashboard = () => {
|
|
||||||
const [activeTab, setActiveTab] = useState("overview");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Overview state
|
|
||||||
const [statistics, setStatistics] = useState(null);
|
|
||||||
|
|
||||||
// Users state
|
|
||||||
const [users, setUsers] = useState([]);
|
|
||||||
const [searchUsers, setSearchUsers] = useState("");
|
|
||||||
|
|
||||||
// Streets state
|
|
||||||
const [streets, setStreets] = useState([]);
|
|
||||||
const [newStreet, setNewStreet] = useState({ name: "", location: "", description: "" });
|
|
||||||
const [editingStreet, setEditingStreet] = useState(null);
|
|
||||||
|
|
||||||
// Rewards state
|
|
||||||
const [rewards, setRewards] = useState([]);
|
|
||||||
const [newReward, setNewReward] = useState({ name: "", pointsCost: "", active: true });
|
|
||||||
const [editingReward, setEditingReward] = useState(null);
|
|
||||||
|
|
||||||
// Content state
|
|
||||||
const [posts, setPosts] = useState([]);
|
|
||||||
const [events, setEvents] = useState([]);
|
|
||||||
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
const axiosConfig = { headers: { "x-auth-token": token } };
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case "overview":
|
|
||||||
fetchStatistics();
|
|
||||||
break;
|
|
||||||
case "users":
|
|
||||||
fetchUsers();
|
|
||||||
break;
|
|
||||||
case "streets":
|
|
||||||
fetchStreets();
|
|
||||||
break;
|
|
||||||
case "rewards":
|
|
||||||
fetchRewards();
|
|
||||||
break;
|
|
||||||
case "content":
|
|
||||||
fetchContent();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
// Overview Tab Functions
|
|
||||||
const fetchStatistics = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const res = await axios.get("/api/analytics", axiosConfig);
|
|
||||||
setStatistics(res.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch statistics:", err);
|
|
||||||
setError("Failed to load statistics");
|
|
||||||
toast.error("Failed to load statistics");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Users Tab Functions
|
|
||||||
const fetchUsers = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const res = await axios.get("/api/users", axiosConfig);
|
|
||||||
setUsers(res.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch users:", err);
|
|
||||||
setError("Failed to load users");
|
|
||||||
toast.error("Failed to load users");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAdminStatus = async (userId, currentStatus) => {
|
|
||||||
if (!window.confirm(`Are you sure you want to ${currentStatus ? "remove" : "grant"} admin status?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.put(
|
|
||||||
`/api/users/${userId}/admin`,
|
|
||||||
{ isAdmin: !currentStatus },
|
|
||||||
axiosConfig
|
|
||||||
);
|
|
||||||
toast.success("Admin status updated");
|
|
||||||
fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update admin status:", err);
|
|
||||||
toast.error("Failed to update admin status");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteUser = async (userId) => {
|
|
||||||
if (!window.confirm("Are you sure you want to delete this user? This action cannot be undone.")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.delete(`/api/users/${userId}`, axiosConfig);
|
|
||||||
toast.success("User deleted");
|
|
||||||
fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete user:", err);
|
|
||||||
toast.error("Failed to delete user");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredUsers = users.filter(user =>
|
|
||||||
user.name?.toLowerCase().includes(searchUsers.toLowerCase()) ||
|
|
||||||
user.email?.toLowerCase().includes(searchUsers.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Streets Tab Functions
|
|
||||||
const fetchStreets = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const res = await axios.get("/api/streets", axiosConfig);
|
|
||||||
setStreets(res.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch streets:", err);
|
|
||||||
setError("Failed to load streets");
|
|
||||||
toast.error("Failed to load streets");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createStreet = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newStreet.name || !newStreet.location) {
|
|
||||||
toast.error("Please fill in all required fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.post("/api/streets", newStreet, axiosConfig);
|
|
||||||
toast.success("Street created successfully");
|
|
||||||
setNewStreet({ name: "", location: "", description: "" });
|
|
||||||
fetchStreets();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create street:", err);
|
|
||||||
toast.error("Failed to create street");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStreet = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!editingStreet.name || !editingStreet.location) {
|
|
||||||
toast.error("Please fill in all required fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.put(`/api/streets/${editingStreet._id}`, editingStreet, axiosConfig);
|
|
||||||
toast.success("Street updated successfully");
|
|
||||||
setEditingStreet(null);
|
|
||||||
fetchStreets();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update street:", err);
|
|
||||||
toast.error("Failed to update street");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteStreet = async (streetId) => {
|
|
||||||
if (!window.confirm("Are you sure you want to delete this street?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.delete(`/api/streets/${streetId}`, axiosConfig);
|
|
||||||
toast.success("Street deleted");
|
|
||||||
fetchStreets();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete street:", err);
|
|
||||||
toast.error("Failed to delete street");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rewards Tab Functions
|
|
||||||
const fetchRewards = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const res = await axios.get("/api/rewards", axiosConfig);
|
|
||||||
setRewards(res.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch rewards:", err);
|
|
||||||
setError("Failed to load rewards");
|
|
||||||
toast.error("Failed to load rewards");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createReward = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newReward.name || !newReward.pointsCost) {
|
|
||||||
toast.error("Please fill in all required fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.post("/api/rewards", {
|
|
||||||
...newReward,
|
|
||||||
pointsCost: parseInt(newReward.pointsCost),
|
|
||||||
}, axiosConfig);
|
|
||||||
toast.success("Reward created successfully");
|
|
||||||
setNewReward({ name: "", pointsCost: "", active: true });
|
|
||||||
fetchRewards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to create reward:", err);
|
|
||||||
toast.error("Failed to create reward");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateReward = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!editingReward.name || !editingReward.pointsCost) {
|
|
||||||
toast.error("Please fill in all required fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.put(`/api/rewards/${editingReward._id}`, {
|
|
||||||
...editingReward,
|
|
||||||
pointsCost: parseInt(editingReward.pointsCost),
|
|
||||||
}, axiosConfig);
|
|
||||||
toast.success("Reward updated successfully");
|
|
||||||
setEditingReward(null);
|
|
||||||
fetchRewards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update reward:", err);
|
|
||||||
toast.error("Failed to update reward");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRewardStatus = async (rewardId, currentStatus) => {
|
|
||||||
try {
|
|
||||||
await axios.patch(
|
|
||||||
`/api/rewards/${rewardId}`,
|
|
||||||
{ active: !currentStatus },
|
|
||||||
axiosConfig
|
|
||||||
);
|
|
||||||
toast.success("Reward status updated");
|
|
||||||
fetchRewards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update reward status:", err);
|
|
||||||
toast.error("Failed to update reward status");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteReward = async (rewardId) => {
|
|
||||||
if (!window.confirm("Are you sure you want to delete this reward?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.delete(`/api/rewards/${rewardId}`, axiosConfig);
|
|
||||||
toast.success("Reward deleted");
|
|
||||||
fetchRewards();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete reward:", err);
|
|
||||||
toast.error("Failed to delete reward");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Content Tab Functions
|
|
||||||
const fetchContent = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const [postsRes, eventsRes] = await Promise.all([
|
|
||||||
axios.get("/api/posts?limit=20", axiosConfig),
|
|
||||||
axios.get("/api/events?limit=20", axiosConfig),
|
|
||||||
]);
|
|
||||||
setPosts(postsRes.data);
|
|
||||||
setEvents(eventsRes.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch content:", err);
|
|
||||||
setError("Failed to load content");
|
|
||||||
toast.error("Failed to load content");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deletePost = async (postId) => {
|
|
||||||
if (!window.confirm("Are you sure you want to delete this post?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.delete(`/api/posts/${postId}`, axiosConfig);
|
|
||||||
toast.success("Post deleted");
|
|
||||||
fetchContent();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete post:", err);
|
|
||||||
toast.error("Failed to delete post");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteEvent = async (eventId) => {
|
|
||||||
if (!window.confirm("Are you sure you want to delete this event?")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await axios.delete(`/api/events/${eventId}`, axiosConfig);
|
|
||||||
toast.success("Event deleted");
|
|
||||||
fetchContent();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to delete event:", err);
|
|
||||||
toast.error("Failed to delete event");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render Overview Tab
|
|
||||||
const renderOverviewTab = () => (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4">Platform Statistics</h4>
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="spinner-border text-primary" role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="alert alert-danger">{error}</div>
|
|
||||||
) : statistics ? (
|
|
||||||
<div className="row mb-4">
|
|
||||||
<div className="col-md-4 mb-3">
|
|
||||||
<div className="card text-white bg-primary">
|
|
||||||
<div className="card-body">
|
|
||||||
<h5 className="card-title">Total Users</h5>
|
|
||||||
<p className="card-text fs-3">{statistics.totalUsers || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-4 mb-3">
|
|
||||||
<div className="card text-white bg-success">
|
|
||||||
<div className="card-body">
|
|
||||||
<h5 className="card-title">Adopted Streets</h5>
|
|
||||||
<p className="card-text fs-3">{statistics.totalStreets || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-4 mb-3">
|
|
||||||
<div className="card text-white bg-info">
|
|
||||||
<div className="card-body">
|
|
||||||
<h5 className="card-title">Completed Tasks</h5>
|
|
||||||
<p className="card-text fs-3">{statistics.totalTasks || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-4 mb-3">
|
|
||||||
<div className="card text-white bg-warning">
|
|
||||||
<div className="card-body">
|
|
||||||
<h5 className="card-title">Active Events</h5>
|
|
||||||
<p className="card-text fs-3">{statistics.totalEvents || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-4 mb-3">
|
|
||||||
<div className="card text-white bg-danger">
|
|
||||||
<div className="card-body">
|
|
||||||
<h5 className="card-title">Total Posts</h5>
|
|
||||||
<p className="card-text fs-3">{statistics.totalPosts || 0}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<h4 className="mt-4 mb-4">Quick Actions</h4>
|
|
||||||
<div className="row">
|
|
||||||
<div className="col-md-3 mb-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-primary w-100"
|
|
||||||
onClick={() => setActiveTab("streets")}
|
|
||||||
>
|
|
||||||
Create Street
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-3 mb-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-success w-100"
|
|
||||||
onClick={() => setActiveTab("rewards")}
|
|
||||||
>
|
|
||||||
Create Reward
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-3 mb-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-info w-100"
|
|
||||||
onClick={() => setActiveTab("users")}
|
|
||||||
>
|
|
||||||
Manage Users
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-3 mb-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-warning w-100"
|
|
||||||
onClick={() => setActiveTab("content")}
|
|
||||||
>
|
|
||||||
Moderate Content
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render Users Tab
|
|
||||||
const renderUsersTab = () => (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4">User Management</h4>
|
|
||||||
<div className="mb-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
placeholder="Search by name or email..."
|
|
||||||
value={searchUsers}
|
|
||||||
onChange={(e) => setSearchUsers(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="spinner-border text-primary" role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="alert alert-danger">{error}</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table table-striped table-hover">
|
|
||||||
<thead className="table-dark">
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Admin Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredUsers.length > 0 ? (
|
|
||||||
filteredUsers.map((user) => (
|
|
||||||
<tr key={user._id}>
|
|
||||||
<td>{user.name}</td>
|
|
||||||
<td>{user.email}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`badge ${user.isAdmin ? "bg-success" : "bg-secondary"}`}>
|
|
||||||
{user.isAdmin ? "Admin" : "User"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-warning me-2"
|
|
||||||
onClick={() => toggleAdminStatus(user._id, user.isAdmin)}
|
|
||||||
>
|
|
||||||
{user.isAdmin ? "Revoke Admin" : "Grant Admin"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-danger"
|
|
||||||
onClick={() => deleteUser(user._id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan="4" className="text-center text-muted">
|
|
||||||
No users found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render Streets Tab
|
|
||||||
const renderStreetsTab = () => (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4">Street Management</h4>
|
|
||||||
|
|
||||||
<div className="card mb-4">
|
|
||||||
<div className="card-header bg-primary text-white">
|
|
||||||
<h5 className="mb-0">{editingStreet ? "Edit Street" : "Create New Street"}</h5>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<form onSubmit={editingStreet ? updateStreet : createStreet}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Street Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={editingStreet?.name || newStreet.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
editingStreet
|
|
||||||
? setEditingStreet({ ...editingStreet, name: e.target.value })
|
|
||||||
: setNewStreet({ ...newStreet, name: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Location *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={editingStreet?.location || newStreet.location}
|
|
||||||
onChange={(e) =>
|
|
||||||
editingStreet
|
|
||||||
? setEditingStreet({ ...editingStreet, location: e.target.value })
|
|
||||||
: setNewStreet({ ...newStreet, location: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Description</label>
|
|
||||||
<textarea
|
|
||||||
className="form-control"
|
|
||||||
rows="3"
|
|
||||||
value={editingStreet?.description || newStreet.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
editingStreet
|
|
||||||
? setEditingStreet({ ...editingStreet, description: e.target.value })
|
|
||||||
: setNewStreet({ ...newStreet, description: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="btn btn-primary me-2">
|
|
||||||
{editingStreet ? "Update Street" : "Create Street"}
|
|
||||||
</button>
|
|
||||||
{editingStreet && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => setEditingStreet(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5>All Streets</h5>
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="spinner-border text-primary" role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="alert alert-danger">{error}</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table table-striped table-hover">
|
|
||||||
<thead className="table-dark">
|
|
||||||
<tr>
|
|
||||||
<th>Street Name</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Adopters</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{streets.length > 0 ? (
|
|
||||||
streets.map((street) => (
|
|
||||||
<tr key={street._id}>
|
|
||||||
<td>{street.name}</td>
|
|
||||||
<td>{street.location}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`badge ${street.status === "adopted" ? "bg-success" : "bg-secondary"}`}>
|
|
||||||
{street.status || "Not Adopted"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{street.adopters?.length || 0}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-warning me-2"
|
|
||||||
onClick={() => setEditingStreet(street)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-danger"
|
|
||||||
onClick={() => deleteStreet(street._id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan="5" className="text-center text-muted">
|
|
||||||
No streets found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render Rewards Tab
|
|
||||||
const renderRewardsTab = () => (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4">Rewards Management</h4>
|
|
||||||
|
|
||||||
<div className="card mb-4">
|
|
||||||
<div className="card-header bg-success text-white">
|
|
||||||
<h5 className="mb-0">{editingReward ? "Edit Reward" : "Create New Reward"}</h5>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<form onSubmit={editingReward ? updateReward : createReward}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Reward Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
value={editingReward?.name || newReward.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
editingReward
|
|
||||||
? setEditingReward({ ...editingReward, name: e.target.value })
|
|
||||||
: setNewReward({ ...newReward, name: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Points Cost *</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-control"
|
|
||||||
value={editingReward?.pointsCost || newReward.pointsCost}
|
|
||||||
onChange={(e) =>
|
|
||||||
editingReward
|
|
||||||
? setEditingReward({ ...editingReward, pointsCost: e.target.value })
|
|
||||||
: setNewReward({ ...newReward, pointsCost: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3 form-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="form-check-input"
|
|
||||||
id="rewardActive"
|
|
||||||
checked={editingReward?.active || newReward.active}
|
|
||||||
onChange={(e) =>
|
|
||||||
editingReward
|
|
||||||
? setEditingReward({ ...editingReward, active: e.target.checked })
|
|
||||||
: setNewReward({ ...newReward, active: e.target.checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label className="form-check-label" htmlFor="rewardActive">
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="btn btn-success me-2">
|
|
||||||
{editingReward ? "Update Reward" : "Create Reward"}
|
|
||||||
</button>
|
|
||||||
{editingReward && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => setEditingReward(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h5>All Rewards</h5>
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="spinner-border text-primary" role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="alert alert-danger">{error}</div>
|
|
||||||
) : (
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table table-striped table-hover">
|
|
||||||
<thead className="table-dark">
|
|
||||||
<tr>
|
|
||||||
<th>Reward Name</th>
|
|
||||||
<th>Points Cost</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rewards.length > 0 ? (
|
|
||||||
rewards.map((reward) => (
|
|
||||||
<tr key={reward._id}>
|
|
||||||
<td>{reward.name}</td>
|
|
||||||
<td>{reward.pointsCost}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`badge ${reward.active ? "bg-success" : "bg-secondary"}`}>
|
|
||||||
{reward.active ? "Active" : "Inactive"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
className={`btn btn-sm me-2 ${reward.active ? "btn-warning" : "btn-info"}`}
|
|
||||||
onClick={() => toggleRewardStatus(reward._id, reward.active)}
|
|
||||||
>
|
|
||||||
{reward.active ? "Deactivate" : "Activate"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-warning me-2"
|
|
||||||
onClick={() => setEditingReward(reward)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-danger"
|
|
||||||
onClick={() => deleteReward(reward._id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan="4" className="text-center text-muted">
|
|
||||||
No rewards found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render Content Tab
|
|
||||||
const renderContentTab = () => (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-4">Content Moderation</h4>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<h5>Recent Posts</h5>
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="spinner-border text-primary" role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="alert alert-danger">{error}</div>
|
|
||||||
) : (
|
|
||||||
<div className="list-group">
|
|
||||||
{posts.length > 0 ? (
|
|
||||||
posts.map((post) => (
|
|
||||||
<div key={post._id} className="list-group-item">
|
|
||||||
<div className="d-flex justify-content-between align-items-start">
|
|
||||||
<div className="flex-grow-1">
|
|
||||||
<h6 className="mb-1">
|
|
||||||
<strong>{post.author?.name || "Unknown"}</strong>
|
|
||||||
</h6>
|
|
||||||
<p className="mb-1 text-muted">
|
|
||||||
{post.content?.substring(0, 100)}...
|
|
||||||
</p>
|
|
||||||
<small className="text-muted">
|
|
||||||
{new Date(post.createdAt).toLocaleDateString()}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-danger"
|
|
||||||
onClick={() => deletePost(post._id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-muted py-4">
|
|
||||||
No posts found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h5>Recent Events</h5>
|
|
||||||
<div className="list-group">
|
|
||||||
{events.length > 0 ? (
|
|
||||||
events.map((event) => (
|
|
||||||
<div key={event._id} className="list-group-item">
|
|
||||||
<div className="d-flex justify-content-between align-items-start">
|
|
||||||
<div className="flex-grow-1">
|
|
||||||
<h6 className="mb-1">
|
|
||||||
<strong>{event.title || event.name}</strong>
|
|
||||||
</h6>
|
|
||||||
<p className="mb-1 text-muted">
|
|
||||||
{event.description?.substring(0, 100)}...
|
|
||||||
</p>
|
|
||||||
<small className="text-muted">
|
|
||||||
{new Date(event.date || event.createdAt).toLocaleDateString()}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-danger"
|
|
||||||
onClick={() => deleteEvent(event._id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-muted py-4">
|
|
||||||
No events found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-dashboard">
|
|
||||||
<h2 className="mb-4">Admin Dashboard</h2>
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<ul className="nav nav-tabs mb-4" role="tablist">
|
|
||||||
<li className="nav-item" role="presentation">
|
|
||||||
<button
|
|
||||||
className={`nav-link ${activeTab === "overview" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("overview")}
|
|
||||||
>
|
|
||||||
Overview
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item" role="presentation">
|
|
||||||
<button
|
|
||||||
className={`nav-link ${activeTab === "users" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("users")}
|
|
||||||
>
|
|
||||||
Users
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item" role="presentation">
|
|
||||||
<button
|
|
||||||
className={`nav-link ${activeTab === "streets" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("streets")}
|
|
||||||
>
|
|
||||||
Streets
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item" role="presentation">
|
|
||||||
<button
|
|
||||||
className={`nav-link ${activeTab === "rewards" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("rewards")}
|
|
||||||
>
|
|
||||||
Rewards
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item" role="presentation">
|
|
||||||
<button
|
|
||||||
className={`nav-link ${activeTab === "content" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("content")}
|
|
||||||
>
|
|
||||||
Content
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div className="tab-content">
|
|
||||||
{activeTab === "overview" && renderOverviewTab()}
|
|
||||||
{activeTab === "users" && renderUsersTab()}
|
|
||||||
{activeTab === "streets" && renderStreetsTab()}
|
|
||||||
{activeTab === "rewards" && renderRewardsTab()}
|
|
||||||
{activeTab === "content" && renderContentTab()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminDashboard;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import React, { useContext } from "react";
|
|
||||||
import { Navigate } from "react-router-dom";
|
|
||||||
import { AuthContext } from "../context/AuthContext";
|
|
||||||
|
|
||||||
const AdminRoute = ({ children }) => {
|
|
||||||
const { auth } = useContext(AuthContext);
|
|
||||||
|
|
||||||
// Show loading state while checking authentication
|
|
||||||
if (auth.loading) {
|
|
||||||
return (
|
|
||||||
<div className="d-flex justify-content-center align-items-center" style={{ height: "100vh" }}>
|
|
||||||
<div className="spinner-border text-primary" role="status">
|
|
||||||
<span className="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
|
||||||
if (!auth.isAuthenticated) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to home if not admin
|
|
||||||
if (!auth.user?.isAdmin) {
|
|
||||||
return <Navigate to="/map" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the protected admin component
|
|
||||||
return children;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminRoute;
|
|
||||||
@@ -31,19 +31,12 @@ const Navbar = () => {
|
|||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<Link className="nav-link" to="/profile" data-testid="nav-profile">Profile</Link>
|
<Link className="nav-link" to="/profile" data-testid="nav-profile">Profile</Link>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<Link className="nav-link" to="/premium" data-testid="nav-premium">Premium</Link>
|
<Link className="nav-link" to="/premium" data-testid="nav-premium">Premium</Link>
|
||||||
</li>
|
</li>
|
||||||
{auth.user?.isAdmin && (
|
<li className="nav-item">
|
||||||
<li className="nav-item">
|
<a onClick={logout} href="#!" className="nav-link" data-testid="logout-button">Logout</a>
|
||||||
<Link className="nav-link text-danger" to="/admin" data-testid="nav-admin">
|
</li>
|
||||||
Admin
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<li className="nav-item">
|
|
||||||
<a onClick={logout} href="#!" className="nav-link" data-testid="logout-button">Logout</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { AuthContext } from "./AuthContext";
|
|||||||
const NotificationProvider = ({ children }) => {
|
const NotificationProvider = ({ children }) => {
|
||||||
const { connected, on, off } = useContext(SSEContext);
|
const { connected, on, off } = useContext(SSEContext);
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
const [hasConnected, setHasConnected] = React.useState(false);
|
|
||||||
|
|
||||||
// Watch connection state for connection status toasts
|
// Watch connection state for connection status toasts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -18,14 +17,12 @@ const NotificationProvider = ({ children }) => {
|
|||||||
toast.success("Connected to real-time updates", {
|
toast.success("Connected to real-time updates", {
|
||||||
toastId: "sse-connected", // Prevent duplicate toasts
|
toastId: "sse-connected", // Prevent duplicate toasts
|
||||||
});
|
});
|
||||||
setHasConnected(true);
|
} else {
|
||||||
} else if (hasConnected) {
|
|
||||||
// Only show reconnecting toast if we were previously connected
|
|
||||||
toast.warning("Connection lost. Reconnecting...", {
|
toast.warning("Connection lost. Reconnecting...", {
|
||||||
toastId: "sse-reconnecting",
|
toastId: "sse-reconnecting",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [connected, hasConnected]);
|
}, [connected]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connected) return;
|
if (!connected) return;
|
||||||
|
|||||||
+10
-88
@@ -2,17 +2,12 @@
|
|||||||
|
|
||||||
// Setup module path to include backend node_modules
|
// Setup module path to include backend node_modules
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const backendPath = path.join(__dirname, '..');
|
const backendPath = path.join(__dirname, '..', 'backend');
|
||||||
process.env.NODE_PATH = path.join(backendPath, 'node_modules') + ':' + (process.env.NODE_PATH || '');
|
process.env.NODE_PATH = path.join(backendPath, 'node_modules') + ':' + (process.env.NODE_PATH || '');
|
||||||
require('module').Module._initPaths();
|
require('module').Module._initPaths();
|
||||||
|
|
||||||
const Nano = require('nano');
|
const Nano = require('nano');
|
||||||
// Load .env file if it exists (for local development)
|
require('dotenv').config({ path: path.join(backendPath, '.env') });
|
||||||
const dotenvPath = path.join(backendPath, '.env');
|
|
||||||
const fs = require('fs');
|
|
||||||
if (fs.existsSync(dotenvPath)) {
|
|
||||||
require('dotenv').config({ path: dotenvPath });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const COUCHDB_URL = process.env.COUCHDB_URL || 'http://localhost:5984';
|
const COUCHDB_URL = process.env.COUCHDB_URL || 'http://localhost:5984';
|
||||||
@@ -372,87 +367,14 @@ class CouchDBSetup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async seedAdminUser() {
|
async run() {
|
||||||
console.log('👤 Seeding admin user...');
|
try {
|
||||||
|
await this.initialize();
|
||||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
|
await this.createDatabase();
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
await this.createIndexes();
|
||||||
|
await this.createSecurityDocument();
|
||||||
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
await this.seedBadges();
|
||||||
console.log('⚠️ ADMIN_EMAIL or ADMIN_PASSWORD not set, skipping admin user creation');
|
await this.verifySetup();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = this.nano.use(COUCHDB_DB_NAME);
|
|
||||||
|
|
||||||
// Check if admin user already exists
|
|
||||||
try {
|
|
||||||
const existing = await db.find({
|
|
||||||
selector: { type: 'user', email: ADMIN_EMAIL },
|
|
||||||
limit: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing.docs.length > 0) {
|
|
||||||
const user = existing.docs[0];
|
|
||||||
if (!user.isAdmin) {
|
|
||||||
user.isAdmin = true;
|
|
||||||
user.updatedAt = new Date().toISOString();
|
|
||||||
await db.insert(user);
|
|
||||||
console.log('✅ Existing user promoted to admin');
|
|
||||||
} else {
|
|
||||||
console.log('ℹ️ Admin user already exists');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Continue with creation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new admin user with hashed password
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
const salt = await bcrypt.genSalt(10);
|
|
||||||
const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, salt);
|
|
||||||
|
|
||||||
const adminUser = {
|
|
||||||
_id: `user_admin_${Date.now()}`,
|
|
||||||
type: 'user',
|
|
||||||
name: 'Administrator',
|
|
||||||
email: ADMIN_EMAIL,
|
|
||||||
password: hashedPassword,
|
|
||||||
isAdmin: true,
|
|
||||||
isPremium: true,
|
|
||||||
points: 0,
|
|
||||||
avatar: null,
|
|
||||||
profilePicture: null,
|
|
||||||
bio: 'System Administrator',
|
|
||||||
location: '',
|
|
||||||
website: '',
|
|
||||||
social: { twitter: '', github: '', linkedin: '' },
|
|
||||||
privacySettings: { profileVisibility: 'private' },
|
|
||||||
preferences: { emailNotifications: true, pushNotifications: true, theme: 'light' },
|
|
||||||
adoptedStreets: [],
|
|
||||||
completedTasks: [],
|
|
||||||
posts: [],
|
|
||||||
events: [],
|
|
||||||
earnedBadges: [],
|
|
||||||
stats: { streetsAdopted: 0, tasksCompleted: 0, postsCreated: 0, eventsParticipated: 0, badgesEarned: 0 },
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.insert(adminUser);
|
|
||||||
console.log(`✅ Admin user created: ${ADMIN_EMAIL}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async run() {
|
|
||||||
try {
|
|
||||||
await this.initialize();
|
|
||||||
await this.createDatabase();
|
|
||||||
await this.createIndexes();
|
|
||||||
await this.createSecurityDocument();
|
|
||||||
await this.seedBadges();
|
|
||||||
await this.seedAdminUser();
|
|
||||||
await this.verifySetup();
|
|
||||||
|
|
||||||
console.log('\n🎉 CouchDB setup completed successfully!');
|
console.log('\n🎉 CouchDB setup completed successfully!');
|
||||||
console.log(`\n📋 Connection Details:`);
|
console.log(`\n📋 Connection Details:`);
|
||||||
|
|||||||
Reference in New Issue
Block a user