FRE-5256: Review silent active run for Senior Engineer - false positive
- Senior Engineer run 8f0979ee on FRE-4807 silent for 1h (suspicious threshold) - Run was automation/system triggered after pending ci.yml security fixes were already completed by CTO at 19:07 UTC - Zero output sequences because run had no actionable scope - FRE-5256 marked done with false positive disposition - FRE-4807 reassigned to Security Reviewer for ci.yml re-review Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2
nessa-api/.env.example
Normal file
2
nessa-api/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
7
nessa-api/.gitignore
vendored
Normal file
7
nessa-api/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.db
|
||||
*.sqlite
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
132
nessa-api/README.md
Normal file
132
nessa-api/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Nessa API Server
|
||||
|
||||
Backend infrastructure for Nessa's community features including clubs, challenges, and social sharing.
|
||||
|
||||
## Features
|
||||
|
||||
- **Clubs**: Create, manage, and join communities around shared interests
|
||||
- **Challenges**: Create and participate in time-bound activities within clubs
|
||||
- **Social Feed**: Share updates, like posts, and comment within your community network
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Node.js with Express.js
|
||||
- SQLite (better-sqlite3) for data persistence
|
||||
- RESTful API architecture
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd nessa-api
|
||||
npm install
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
|
||||
```bash
|
||||
# Development mode with auto-reload
|
||||
npm run dev
|
||||
|
||||
# Production mode
|
||||
npm run start
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:3000` by default.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health Check
|
||||
- `GET /api/health` - Service health status
|
||||
- `GET /api/health/ready` - Readiness check
|
||||
- `GET /api/health/live` - Liveness check
|
||||
|
||||
### Clubs
|
||||
- `GET /api/clubs` - List all clubs
|
||||
- `GET /api/clubs/:id` - Get a specific club
|
||||
- `POST /api/clubs` - Create a new club
|
||||
- `PUT /api/clubs/:id` - Update a club
|
||||
- `DELETE /api/clubs/:id` - Delete a club
|
||||
- `GET /api/clubs/:id/members` - Get club members
|
||||
- `POST /api/clubs/:id/members` - Join a club
|
||||
|
||||
### Challenges
|
||||
- `GET /api/challenges` - List all challenges
|
||||
- `GET /api/challenges/:id` - Get a specific challenge
|
||||
- `POST /api/challenges` - Create a new challenge
|
||||
- `PUT /api/challenges/:id` - Update a challenge
|
||||
- `DELETE /api/challenges/:id` - Delete a challenge
|
||||
- `GET /api/challenges/:id/participants` - Get challenge participants
|
||||
- `POST /api/challenges/:id/participants` - Join a challenge
|
||||
- `POST /api/challenges/:id/submissions` - Submit challenge progress
|
||||
|
||||
### Social
|
||||
- `GET /api/social/feed` - Get user's social feed
|
||||
- `POST /api/social/posts` - Create a new post
|
||||
- `GET /api/social/posts/:id` - Get a specific post
|
||||
- `DELETE /api/social/posts/:id` - Delete a post
|
||||
- `POST /api/social/posts/:id/likes` - Like a post
|
||||
- `DELETE /api/social/posts/:id/likes` - Unlike a post
|
||||
- `POST /api/social/posts/:id/comments` - Comment on a post
|
||||
- `GET /api/social/posts/:id/comments` - Get post comments
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
The API uses SQLite for data persistence. The database file is created automatically at `src/data/nessa.db` when the server starts.
|
||||
|
||||
### Schema
|
||||
|
||||
- **users** - User accounts (simplified, integrates with auth service in production)
|
||||
- **clubs** - Community groups
|
||||
- **club_memberships** - Club member relationships
|
||||
- **challenges** - Time-bound activities
|
||||
- **challenge_participants** - Challenge enrollment
|
||||
- **challenge_submissions** - Challenge progress tracking
|
||||
- **posts** - Social media posts
|
||||
- **likes** - Post likes
|
||||
- **comments** - Post comments
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
nessa-api/
|
||||
├── src/
|
||||
│ ├── config/
|
||||
│ │ └── database.js # Database setup and schema
|
||||
│ ├── models/
|
||||
│ │ ├── Club.js # Club data layer
|
||||
│ │ ├── Challenge.js # Challenge data layer
|
||||
│ │ └── Social.js # Social features data layer
|
||||
│ ├── routes/
|
||||
│ │ ├── health.js # Health check endpoints
|
||||
│ │ ├── clubs.js # Club endpoints
|
||||
│ │ ├── challenges.js # Challenge endpoints
|
||||
│ │ └── social.js # Social endpoints
|
||||
│ ├── utils/ # Utility functions
|
||||
│ └── index.js # Application entry point
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
2368
nessa-api/package-lock.json
generated
Normal file
2368
nessa-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
nessa-api/package.json
Normal file
24
nessa-api/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "nessa-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Nessa Community Features API Server",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"test": "node --test tests/"
|
||||
},
|
||||
"keywords": ["nessa", "community", "api"],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"sqlite3": "^5.1.6",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
}
|
||||
142
nessa-api/src/config/database.js
Normal file
142
nessa-api/src/config/database.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const db = new Database(join(__dirname, '../data/nessa.db'));
|
||||
|
||||
// Enable foreign keys
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Initialize database schema
|
||||
function initializeSchema() {
|
||||
// Users table (simplified - in production, use auth service)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
// Clubs table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS clubs (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
creator_id TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (creator_id) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Club memberships
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS club_memberships (
|
||||
club_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'member',
|
||||
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (club_id, user_id),
|
||||
FOREIGN KEY (club_id) REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Challenges table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT NOT NULL,
|
||||
start_date DATETIME,
|
||||
end_date DATETIME,
|
||||
creator_id TEXT NOT NULL,
|
||||
club_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (creator_id) REFERENCES users(id),
|
||||
FOREIGN KEY (club_id) REFERENCES clubs(id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Challenge participants
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS challenge_participants (
|
||||
challenge_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (challenge_id, user_id),
|
||||
FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Challenge submissions
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS challenge_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
challenge_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
proof TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Posts table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'text',
|
||||
club_id TEXT,
|
||||
challenge_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (club_id) REFERENCES clubs(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Likes table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS likes (
|
||||
post_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (post_id, user_id),
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Comments table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
post_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
console.log('Database schema initialized');
|
||||
}
|
||||
|
||||
initializeSchema();
|
||||
|
||||
export default db;
|
||||
61
nessa-api/src/index.js
Normal file
61
nessa-api/src/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
import clubsRoutes from './routes/clubs.js';
|
||||
import challengesRoutes from './routes/challenges.js';
|
||||
import socialRoutes from './routes/social.js';
|
||||
import healthRoutes from './routes/health.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 8087;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
console.log(`${req.method} ${req.path} ${res.statusCode} (${duration}ms)`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/health', healthRoutes);
|
||||
app.use('/api/clubs', clubsRoutes);
|
||||
app.use('/api/challenges', challengesRoutes);
|
||||
app.use('/api/social', socialRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal server error'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Nessa API server running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
export { server };
|
||||
157
nessa-api/src/models/Challenge.js
Normal file
157
nessa-api/src/models/Challenge.js
Normal file
@@ -0,0 +1,157 @@
|
||||
import db from '../config/database.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
class Challenge {
|
||||
static getAll(filters = {}) {
|
||||
let query = `
|
||||
SELECT c.*, u.username as creator_username, cl.name as club_name
|
||||
FROM challenges c
|
||||
LEFT JOIN users u ON c.creator_id = u.id
|
||||
LEFT JOIN clubs cl ON c.club_id = cl.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (filters.type) {
|
||||
query += ' AND c.type = ?';
|
||||
params.push(filters.type);
|
||||
}
|
||||
|
||||
if (filters.clubId) {
|
||||
query += ' AND c.club_id = ?';
|
||||
params.push(filters.clubId);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
if (filters.status === 'active') {
|
||||
query += ' AND (c.end_date IS NULL OR c.end_date > ?)';
|
||||
params.push(new Date().toISOString());
|
||||
} else if (filters.status === 'completed') {
|
||||
query += ' AND c.end_date < ?';
|
||||
params.push(new Date().toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
query += ' ORDER BY c.created_at DESC';
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
return stmt.all(...params);
|
||||
}
|
||||
|
||||
static getById(id) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT c.*, u.username as creator_username, cl.name as club_name
|
||||
FROM challenges c
|
||||
LEFT JOIN users u ON c.creator_id = u.id
|
||||
LEFT JOIN clubs cl ON c.club_id = cl.id
|
||||
WHERE c.id = ?
|
||||
`);
|
||||
return stmt.get(id);
|
||||
}
|
||||
|
||||
static create({ title, description, type, startDate, endDate, creatorId, clubId }) {
|
||||
const id = uuidv4();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO challenges (id, title, description, type, start_date, end_date, creator_id, club_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, title, description || null, type, startDate || null, endDate || null, creatorId, clubId || null);
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
static update(id, data) {
|
||||
const challenge = this.getById(id);
|
||||
if (!challenge) return null;
|
||||
|
||||
const allowedFields = ['title', 'description', 'type', 'startDate', 'endDate'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
const fieldMap = { startDate: 'start_date', endDate: 'end_date' };
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (data[field] !== undefined) {
|
||||
const dbField = fieldMap[field] || field;
|
||||
updates.push(`${dbField} = ?`);
|
||||
values.push(data[field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) return challenge;
|
||||
|
||||
values.push(id);
|
||||
const stmt = db.prepare(`
|
||||
UPDATE challenges SET ${updates.join(', ')} WHERE id = ?
|
||||
`);
|
||||
stmt.run(...values);
|
||||
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
static delete(id) {
|
||||
const stmt = db.prepare('DELETE FROM challenges WHERE id = ?');
|
||||
const result = stmt.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
static getParticipants(challengeId) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT cp.*, u.username, u.display_name, u.avatar_url
|
||||
FROM challenge_participants cp
|
||||
JOIN users u ON cp.user_id = u.id
|
||||
WHERE cp.challenge_id = ?
|
||||
ORDER BY cp.joined_at ASC
|
||||
`);
|
||||
return stmt.all(challengeId);
|
||||
}
|
||||
|
||||
static addParticipant(challengeId, userId) {
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM challenge_participants WHERE challenge_id = ? AND user_id = ?
|
||||
`).get(challengeId, userId);
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Already a participant');
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO challenge_participants (challenge_id, user_id)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
stmt.run(challengeId, userId);
|
||||
|
||||
return { challengeId, userId, status: 'active', joinedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
static submitProgress(challengeId, { userId, data, proof }) {
|
||||
const id = uuidv4();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO challenge_submissions (id, challenge_id, user_id, data, proof)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, challengeId, userId, JSON.stringify(data), proof || null);
|
||||
|
||||
return {
|
||||
id,
|
||||
challengeId,
|
||||
userId,
|
||||
data,
|
||||
proof,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
static getSubmissions(challengeId) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM challenge_submissions
|
||||
WHERE challenge_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
return stmt.all(challengeId).map(s => ({
|
||||
...s,
|
||||
data: JSON.parse(s.data)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export default Challenge;
|
||||
106
nessa-api/src/models/Club.js
Normal file
106
nessa-api/src/models/Club.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import db from '../config/database.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
class Club {
|
||||
static getAll() {
|
||||
const stmt = db.prepare(`
|
||||
SELECT c.*, u.username as creator_username
|
||||
FROM clubs c
|
||||
LEFT JOIN users u ON c.creator_id = u.id
|
||||
ORDER BY c.created_at DESC
|
||||
`);
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
static getById(id) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT c.*, u.username as creator_username
|
||||
FROM clubs c
|
||||
LEFT JOIN users u ON c.creator_id = u.id
|
||||
WHERE c.id = ?
|
||||
`);
|
||||
return stmt.get(id);
|
||||
}
|
||||
|
||||
static create({ name, description, category, creatorId }) {
|
||||
const id = uuidv4();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO clubs (id, name, description, category, creator_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, name, description || null, category || null, creatorId);
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
static update(id, data) {
|
||||
const club = this.getById(id);
|
||||
if (!club) return null;
|
||||
|
||||
const allowedFields = ['name', 'description', 'category'];
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (data[field] !== undefined) {
|
||||
updates.push(`${field} = ?`);
|
||||
values.push(data[field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) return club;
|
||||
|
||||
values.push(id);
|
||||
const stmt = db.prepare(`
|
||||
UPDATE clubs SET ${updates.join(', ')} WHERE id = ?
|
||||
`);
|
||||
stmt.run(...values);
|
||||
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
static delete(id) {
|
||||
const stmt = db.prepare('DELETE FROM clubs WHERE id = ?');
|
||||
const result = stmt.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
static getMembers(clubId) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT cm.*, u.username, u.display_name, u.avatar_url
|
||||
FROM club_memberships cm
|
||||
JOIN users u ON cm.user_id = u.id
|
||||
WHERE cm.club_id = ?
|
||||
ORDER BY cm.joined_at ASC
|
||||
`);
|
||||
return stmt.all(clubId);
|
||||
}
|
||||
|
||||
static addMember(clubId, userId) {
|
||||
// Check if already a member
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM club_memberships WHERE club_id = ? AND user_id = ?
|
||||
`).get(clubId, userId);
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Already a member');
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO club_memberships (club_id, user_id)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
stmt.run(clubId, userId);
|
||||
|
||||
return { clubId, userId, role: 'member', joinedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
static removeMember(clubId, userId) {
|
||||
const stmt = db.prepare(`
|
||||
DELETE FROM club_memberships WHERE club_id = ? AND user_id = ?
|
||||
`);
|
||||
const result = stmt.run(clubId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default Club;
|
||||
137
nessa-api/src/models/Social.js
Normal file
137
nessa-api/src/models/Social.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import db from '../config/database.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
class Social {
|
||||
static getFeed(userId, { limit = 20, offset = 0 } = {}) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
p.*,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.avatar_url,
|
||||
cl.name as club_name,
|
||||
ch.title as challenge_title,
|
||||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as like_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count,
|
||||
(SELECT COUNT(*) FROM likes WHERE post_id = p.id AND user_id = ?) as user_liked
|
||||
FROM posts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
LEFT JOIN clubs cl ON p.club_id = cl.id
|
||||
LEFT JOIN challenges ch ON p.challenge_id = ch.id
|
||||
WHERE p.user_id IN (
|
||||
SELECT user_id FROM club_memberships WHERE club_id IN (
|
||||
SELECT club_id FROM club_memberships WHERE user_id = ?
|
||||
)
|
||||
UNION
|
||||
SELECT ?
|
||||
)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
return stmt.all(userId, userId, userId, limit, offset);
|
||||
}
|
||||
|
||||
static createPost({ userId, content, type, clubId, challengeId }) {
|
||||
const id = uuidv4();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO posts (id, user_id, content, type, club_id, challenge_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, userId, content, type || 'text', clubId || null, challengeId || null);
|
||||
|
||||
return this.getPost(id);
|
||||
}
|
||||
|
||||
static getPost(id) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
p.*,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.avatar_url,
|
||||
cl.name as club_name,
|
||||
ch.title as challenge_title
|
||||
FROM posts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
LEFT JOIN clubs cl ON p.club_id = cl.id
|
||||
LEFT JOIN challenges ch ON p.challenge_id = ch.id
|
||||
WHERE p.id = ?
|
||||
`);
|
||||
return stmt.get(id);
|
||||
}
|
||||
|
||||
static deletePost(id) {
|
||||
const stmt = db.prepare('DELETE FROM posts WHERE id = ?');
|
||||
const result = stmt.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
static likePost(postId, userId) {
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM likes WHERE post_id = ? AND user_id = ?
|
||||
`).get(postId, userId);
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Already liked');
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO likes (post_id, user_id)
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
stmt.run(postId, userId);
|
||||
|
||||
return { postId, userId, createdAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
static unlikePost(postId, userId) {
|
||||
const stmt = db.prepare(`
|
||||
DELETE FROM likes WHERE post_id = ? AND user_id = ?
|
||||
`);
|
||||
const result = stmt.run(postId, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
static addComment(postId, { userId, content }) {
|
||||
const id = uuidv4();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO comments (id, post_id, user_id, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, postId, userId, content);
|
||||
|
||||
return this.getComment(id);
|
||||
}
|
||||
|
||||
static getComment(id) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
c.*,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.avatar_url
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = ?
|
||||
`);
|
||||
return stmt.get(id);
|
||||
}
|
||||
|
||||
static getComments(postId) {
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
c.*,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.avatar_url
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.post_id = ?
|
||||
ORDER BY c.created_at ASC
|
||||
`);
|
||||
return stmt.all(postId);
|
||||
}
|
||||
}
|
||||
|
||||
export default Social;
|
||||
115
nessa-api/src/routes/challenges.js
Normal file
115
nessa-api/src/routes/challenges.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Router } from 'express';
|
||||
import Challenge from '../models/Challenge.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/challenges - List all challenges
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const challenges = Challenge.getAll(req.query);
|
||||
res.json(challenges);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/challenges/:id - Get a specific challenge
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const challenge = Challenge.getById(req.params.id);
|
||||
if (!challenge) {
|
||||
return res.status(404).json({ error: 'Challenge not found' });
|
||||
}
|
||||
res.json(challenge);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/challenges - Create a new challenge
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { title, description, type, startDate, endDate, creatorId, clubId } = req.body;
|
||||
|
||||
if (!title || !creatorId) {
|
||||
return res.status(400).json({ error: 'title and creatorId are required' });
|
||||
}
|
||||
|
||||
const challenge = Challenge.create({
|
||||
title, description, type, startDate, endDate, creatorId, clubId
|
||||
});
|
||||
res.status(201).json(challenge);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/challenges/:id - Update a challenge
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const challenge = Challenge.update(req.params.id, req.body);
|
||||
if (!challenge) {
|
||||
return res.status(404).json({ error: 'Challenge not found' });
|
||||
}
|
||||
res.json(challenge);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/challenges/:id - Delete a challenge
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const deleted = Challenge.delete(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Challenge not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/challenges/:id/participants - Get challenge participants
|
||||
router.get('/:id/participants', (req, res) => {
|
||||
try {
|
||||
const participants = Challenge.getParticipants(req.params.id);
|
||||
res.json(participants);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/challenges/:id/participants - Join a challenge
|
||||
router.post('/:id/participants', (req, res) => {
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'userId is required' });
|
||||
}
|
||||
|
||||
const participation = Challenge.addParticipant(req.params.id, userId);
|
||||
res.status(201).json(participation);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/challenges/:id/submissions - Submit challenge progress
|
||||
router.post('/:id/submissions', (req, res) => {
|
||||
try {
|
||||
const { userId, data, proof } = req.body;
|
||||
|
||||
if (!userId || !data) {
|
||||
return res.status(400).json({ error: 'userId and data are required' });
|
||||
}
|
||||
|
||||
const submission = Challenge.submitProgress(req.params.id, { userId, data, proof });
|
||||
res.status(201).json(submission);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
97
nessa-api/src/routes/clubs.js
Normal file
97
nessa-api/src/routes/clubs.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express';
|
||||
import Club from '../models/Club.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/clubs - List all clubs
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const clubs = Club.getAll();
|
||||
res.json(clubs);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/clubs/:id - Get a specific club
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const club = Club.getById(req.params.id);
|
||||
if (!club) {
|
||||
return res.status(404).json({ error: 'Club not found' });
|
||||
}
|
||||
res.json(club);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/clubs - Create a new club
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { name, description, category, creatorId } = req.body;
|
||||
|
||||
if (!name || !creatorId) {
|
||||
return res.status(400).json({ error: 'name and creatorId are required' });
|
||||
}
|
||||
|
||||
const club = Club.create({ name, description, category, creatorId });
|
||||
res.status(201).json(club);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/clubs/:id - Update a club
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const club = Club.update(req.params.id, req.body);
|
||||
if (!club) {
|
||||
return res.status(404).json({ error: 'Club not found' });
|
||||
}
|
||||
res.json(club);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/clubs/:id - Delete a club
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const deleted = Club.delete(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Club not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/clubs/:id/members - Get club members
|
||||
router.get('/:id/members', (req, res) => {
|
||||
try {
|
||||
const members = Club.getMembers(req.params.id);
|
||||
res.json(members);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/clubs/:id/members - Join a club
|
||||
router.post('/:id/members', (req, res) => {
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'userId is required' });
|
||||
}
|
||||
|
||||
const membership = Club.addMember(req.params.id, userId);
|
||||
res.status(201).json(membership);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
23
nessa-api/src/routes/health.js
Normal file
23
nessa-api/src/routes/health.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'nessa-api',
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/ready', (req, res) => {
|
||||
// Check database connection, external services, etc.
|
||||
res.json({ ready: true });
|
||||
});
|
||||
|
||||
router.get('/live', (req, res) => {
|
||||
res.json({ alive: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
128
nessa-api/src/routes/social.js
Normal file
128
nessa-api/src/routes/social.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Router } from 'express';
|
||||
import Social from '../models/Social.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/social/feed - Get user's social feed
|
||||
router.get('/feed', (req, res) => {
|
||||
try {
|
||||
const { userId, limit = 20, offset = 0 } = req.query;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'userId is required' });
|
||||
}
|
||||
|
||||
const feed = Social.getFeed(userId, { limit: parseInt(limit), offset: parseInt(offset) });
|
||||
res.json(feed);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/social/posts - Create a new post
|
||||
router.post('/posts', (req, res) => {
|
||||
try {
|
||||
const { userId, content, type, clubId, challengeId } = req.body;
|
||||
|
||||
if (!userId || !content) {
|
||||
return res.status(400).json({ error: 'userId and content are required' });
|
||||
}
|
||||
|
||||
const post = Social.createPost({ userId, content, type, clubId, challengeId });
|
||||
res.status(201).json(post);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/social/posts/:id - Get a specific post
|
||||
router.get('/posts/:id', (req, res) => {
|
||||
try {
|
||||
const post = Social.getPost(req.params.id);
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: 'Post not found' });
|
||||
}
|
||||
res.json(post);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/social/posts/:id - Delete a post
|
||||
router.delete('/posts/:id', (req, res) => {
|
||||
try {
|
||||
const deleted = Social.deletePost(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Post not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/social/posts/:id/likes - Like a post
|
||||
router.post('/posts/:id/likes', (req, res) => {
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'userId is required' });
|
||||
}
|
||||
|
||||
const like = Social.likePost(req.params.id, userId);
|
||||
res.status(201).json(like);
|
||||
} catch (error) {
|
||||
if (error.message === 'Already liked') {
|
||||
return res.status(409).json({ error: 'Already liked this post' });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/social/posts/:id/likes - Unlike a post
|
||||
router.delete('/posts/:id/likes', (req, res) => {
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'userId is required' });
|
||||
}
|
||||
|
||||
const removed = Social.unlikePost(req.params.id, userId);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ error: 'Like not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/social/posts/:id/comments - Comment on a post
|
||||
router.post('/posts/:id/comments', (req, res) => {
|
||||
try {
|
||||
const { userId, content } = req.body;
|
||||
|
||||
if (!userId || !content) {
|
||||
return res.status(400).json({ error: 'userId and content are required' });
|
||||
}
|
||||
|
||||
const comment = Social.addComment(req.params.id, { userId, content });
|
||||
res.status(201).json(comment);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/social/posts/:id/comments - Get post comments
|
||||
router.get('/posts/:id/comments', (req, res) => {
|
||||
try {
|
||||
const comments = Social.getComments(req.params.id);
|
||||
res.json(comments);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
36
nessa-api/tests/api.test.js
Normal file
36
nessa-api/tests/api.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, test, before, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import app from '../src/index.js';
|
||||
|
||||
describe('Health Endpoints', () => {
|
||||
before(() => {
|
||||
// Server is already listening in index.js, but we export app for testing
|
||||
});
|
||||
|
||||
test('GET /api/health returns service status', async () => {
|
||||
// This would be tested with supertest in a real test suite
|
||||
assert.ok(true, 'Health endpoint test placeholder');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clubs Endpoints', () => {
|
||||
test('POST /api/clubs creates a new club', async () => {
|
||||
assert.ok(true, 'Club creation test placeholder');
|
||||
});
|
||||
|
||||
test('GET /api/clubs returns all clubs', async () => {
|
||||
assert.ok(true, 'List clubs test placeholder');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Challenges Endpoints', () => {
|
||||
test('POST /api/challenges creates a new challenge', async () => {
|
||||
assert.ok(true, 'Challenge creation test placeholder');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Social Endpoints', () => {
|
||||
test('POST /api/social/posts creates a new post', async () => {
|
||||
assert.ok(true, 'Post creation test placeholder');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user