Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ FutureTracker is a modern, full-featured SaaS application designed to help stude
- **📈 Analytics Dashboard**: Track success rates, trends, and conversion funnels
- **📅 Deadline Management**: Never miss an important deadline with calendar integration
- **📄 PDF Reports**: Export detailed reports for your records
- **🔗 Share links**: Generate revocable read-only opportunity links with descriptions, deadlines, application CTAs, expiry, and optional passcode
- **🧠 Interview prep**: Per-internship workspace for company research, Q&A, technical topics, STAR behavioral answers, and reflections — see [`docs/interview-prep.md`](docs/interview-prep.md)
- **📊 ATS resume hints**: Client-side PDF/DOCX analysis with rule-based scoring on upload — see [`docs/documents-and-ats.md`](docs/documents-and-ats.md)
- **🟢 Service status**: Live uptime page linked from the app footer and navbar
Expand Down Expand Up @@ -68,6 +69,7 @@ FutureTracker is a modern, full-featured SaaS application designed to help stude
- **📋 Status Board**: Kanban-style board with drag-and-drop status updates
- **📈 Analytics**: Charts for status distribution, weekly trends, conversion funnels, and deadline heatmaps
- **📄 PDF Export**: Generate professional reports with multiple export options
- **🔗 Opportunity sharing**: Share redacted, read-only opportunity details at `/share/:token` without requiring viewer sign-in
- **📎 Documents**: Upload resumes, cover letters, and portfolio links; track which documents were used for each internship; optional **ATS-style score** on PDF/DOCX upload
- **🎯 Interview pipeline**: Multi-round tracking for internships (OA → technical → HR → final) with timeline UI and auto-synced Kanban status — see [`docs/interview-rounds.md`](docs/interview-rounds.md)
- **🧠 Interview preparation**: Tabbed prep workspace per internship (research, questions, topics, STAR behavioral, reflection) — see [`docs/interview-prep.md`](docs/interview-prep.md)
Expand Down Expand Up @@ -160,6 +162,7 @@ erDiagram
USERS ||--o{ OPPORTUNITIES : tracks
USERS ||--o{ DOCUMENTS : owns
USERS ||--o{ OPPORTUNITY_ROUNDS : owns
USERS ||--o{ SHARE_LINKS : creates
OPPORTUNITIES ||--o{ OPPORTUNITY_DOCUMENTS : uses
OPPORTUNITIES ||--o{ OPPORTUNITY_ROUNDS : has
DOCUMENTS ||--o{ OPPORTUNITY_DOCUMENTS : linked_to
Expand Down Expand Up @@ -219,6 +222,22 @@ erDiagram
uuid document_id FK
timestamptz submitted_at
}

SHARE_LINKS {
uuid id PK
uuid user_id FK
text token_hash UK
text token_ciphertext
text token_iv
text token_auth_tag
jsonb snapshot
text snapshot_type
timestamptz expires_at
boolean is_active
int view_count
text passcode_hash
text passcode_salt
}
Comment thread
Venkat-Kolasani marked this conversation as resolved.
```

All tables use **Row-Level Security (RLS)** so each user only accesses their own data. Full SQL migrations live in [`docs/supabase-schema.sql`](docs/supabase-schema.sql) and the feature-specific files below.
Expand All @@ -230,6 +249,7 @@ All tables use **Row-Level Security (RLS)** so each user only accesses their own
| [`docs/opportunity-rounds-migration.sql`](docs/opportunity-rounds-migration.sql) | Interview round pipeline |
| [`docs/interview-prep-migration.sql`](docs/interview-prep-migration.sql) | Interview prep workspace |
| [`docs/hackathon-collaboration-migration.sql`](docs/hackathon-collaboration-migration.sql) | Hackathon teams, tasks, ideas |
| [`docs/share-links-migration.sql`](docs/share-links-migration.sql) | Dashboard share links |

## 🚀 Getting Started

Expand Down
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,8 @@ CLERK_SECRET_KEY=sk_test_...
# Supabase Database (get from supabase.com project settings)
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...

# Share links
# Used to encrypt recoverable share tokens so owners can copy active links again.
# Generate with: openssl rand -base64 32
SHARE_LINK_ENCRYPTION_KEY=replace-with-random-32-byte-secret
4 changes: 4 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const analyticsRoutes = require('./routes/analytics');
const documentsRoutes = require('./routes/documents');
const hackathonsRoutes = require('./routes/hackathons');
const interviewPrepRoutes = require('./routes/interview-prep');
const shareLinksRoutes = require('./routes/share-links');
const publicShareLinksRoutes = require('./routes/public-share-links');

const app = express();

Expand Down Expand Up @@ -202,6 +204,8 @@ app.use('/api/analytics', requireAuth, analyticsRoutes);
app.use('/api/documents', requireAuth, writeOperationsLimiter, documentsRoutes);
app.use('/api/hackathons', requireAuth, writeOperationsLimiter, hackathonsRoutes);
app.use('/api/interview-prep', requireAuth, writeOperationsLimiter, interviewPrepRoutes);
app.use('/api/share-links', requireAuth, writeOperationsLimiter, shareLinksRoutes);
app.use('/api/public/share-links', publicShareLinksRoutes);

app.get('/api/me', requireAuth, (req, res) => {
res.json({
Expand Down
314 changes: 314 additions & 0 deletions backend/src/lib/shareLinks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
const crypto = require('crypto');

const TOKEN_BYTES = 32;
const TOKEN_IV_BYTES = 12;
const PASSCODE_SALT_BYTES = 16;
const PASSCODE_ITERATIONS = 120000;
const PASSCODE_KEY_LENGTH = 32;
const TOKEN_HASH_ALGORITHM = 'sha256';
const TOKEN_ENCRYPTION_ALGORITHM = 'aes-256-gcm';
const PASSCODE_DIGEST = 'sha256';

const SHARE_FIELD_DEFAULTS = {
status: true,
rounds: true,
rejectedRound: true,
dateApplied: true,
description: true,
deadline: true,
applicationLink: true,
};

function generateShareToken() {
return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
}

function hashToken(token) {
return crypto.createHash(TOKEN_HASH_ALGORITHM).update(token).digest('hex');
}

function getTokenEncryptionKey() {
const secret = process.env.SHARE_LINK_ENCRYPTION_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!secret) {
return null;
}

return crypto.createHash('sha256').update(secret).digest();
}

function encryptShareToken(token) {
const key = getTokenEncryptionKey();
if (!key) {
return {
tokenCiphertext: null,
tokenIv: null,
tokenAuthTag: null,
};
}

const iv = crypto.randomBytes(TOKEN_IV_BYTES);
const cipher = crypto.createCipheriv(TOKEN_ENCRYPTION_ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();

return {
tokenCiphertext: ciphertext.toString('base64'),
tokenIv: iv.toString('base64'),
tokenAuthTag: authTag.toString('base64'),
};
}

function decryptShareToken(share) {
if (!share?.token_ciphertext || !share?.token_iv || !share?.token_auth_tag) {
return null;
}

const key = getTokenEncryptionKey();
if (!key) {
return null;
}

try {
const decipher = crypto.createDecipheriv(
TOKEN_ENCRYPTION_ALGORITHM,
key,
Buffer.from(share.token_iv, 'base64')
);
decipher.setAuthTag(Buffer.from(share.token_auth_tag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(share.token_ciphertext, 'base64')),
decipher.final(),
]).toString('utf8');
} catch (error) {
console.warn('Unable to decrypt share token', { shareId: share.id, message: error.message });
return null;
}
}

function createPasscodeHash(passcode) {
if (!passcode) {
return { passcodeHash: null, passcodeSalt: null };
}

const passcodeSalt = crypto.randomBytes(PASSCODE_SALT_BYTES).toString('hex');
const passcodeHash = crypto
.pbkdf2Sync(passcode, passcodeSalt, PASSCODE_ITERATIONS, PASSCODE_KEY_LENGTH, PASSCODE_DIGEST)
.toString('hex');

return { passcodeHash, passcodeSalt };
}

function verifyPasscode(passcode, passcodeHash, passcodeSalt) {
if (!passcodeHash || !passcodeSalt) {
return true;
}

const candidate = crypto
.pbkdf2Sync(passcode || '', passcodeSalt, PASSCODE_ITERATIONS, PASSCODE_KEY_LENGTH, PASSCODE_DIGEST)
.toString('hex');

return crypto.timingSafeEqual(Buffer.from(candidate, 'hex'), Buffer.from(passcodeHash, 'hex'));
}

function resolveExpiresAt(expiry) {
if (expiry === '24h') {
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
}

if (expiry === '7d') {
return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
}

return null;
}

function isShareExpired(share) {
return Boolean(share?.expires_at && new Date(share.expires_at).getTime() <= Date.now());
}

function isShareUnavailable(share) {
return !share || share.is_active === false || isShareExpired(share);
}

function normalizeFieldOptions(fields = {}) {
return {
status: fields.status !== false,
rounds: fields.rounds !== false,
rejectedRound: fields.rejectedRound !== false,
dateApplied: fields.dateApplied !== false,
description: fields.description !== false,
deadline: fields.deadline !== false,
applicationLink: fields.applicationLink !== false,
};
}

function toPublicOpportunity(opportunity, fields) {
const item = {
id: opportunity.id,
title: opportunity.title,
category: opportunity.category,
};

if (fields.status) {
item.status = opportunity.status || 'applied';
}

if (fields.description) {
item.description = opportunity.description || null;
}

if (fields.deadline) {
item.deadline = opportunity.deadline || null;
}

if (fields.applicationLink) {
item.applicationLink = opportunity.link || null;
}

if (fields.rounds) {
item.currentRoundNumber = opportunity.current_round_number || null;
}

if (fields.rejectedRound) {
item.rejectedRoundNumber = opportunity.rejected_round_number || null;
}

if (fields.dateApplied) {
item.dateApplied = opportunity.created_at || null;
}

return item;
Comment thread
Venkat-Kolasani marked this conversation as resolved.
}

function buildShareSnapshot({ opportunities, fields, expiry, selectedOpportunityIds }) {
const normalizedFields = normalizeFieldOptions(fields);
const publicOpportunities = opportunities.map((opportunity) =>
toPublicOpportunity(opportunity, normalizedFields)
);
const today = new Date();
today.setHours(0, 0, 0, 0);

const statusCounts = publicOpportunities.reduce((counts, opportunity) => {
const status = opportunity.status || 'hidden';
counts[status] = (counts[status] || 0) + 1;
return counts;
}, {});

const categoryCounts = publicOpportunities.reduce((counts, opportunity) => {
const category = opportunity.category || 'uncategorized';
counts[category] = (counts[category] || 0) + 1;
return counts;
}, {});

const opportunitiesWithLinks = publicOpportunities.filter((opportunity) => opportunity.applicationLink).length;
const upcomingDeadlineCount = publicOpportunities.filter((opportunity) => {
if (!opportunity.deadline) return false;
const deadline = new Date(opportunity.deadline);
deadline.setHours(0, 0, 0, 0);
return deadline >= today;
}).length;
const expiredDeadlineCount = publicOpportunities.filter((opportunity) => {
if (!opportunity.deadline) return false;
const deadline = new Date(opportunity.deadline);
deadline.setHours(0, 0, 0, 0);
return deadline < today;
}).length;

return {
version: 2,
generatedAt: new Date().toISOString(),
shareType: 'placement_dashboard',
options: {
fields: normalizedFields,
expiry,
selectionMode: selectedOpportunityIds?.length ? 'specific' : 'all',
},
summary: {
total: publicOpportunities.length,
statusCounts,
categoryCounts,
selected: statusCounts.selected || 0,
rejected: statusCounts.rejected || 0,
ghosted: statusCounts.ghosted || 0,
opportunitiesWithLinks,
upcomingDeadlineCount,
expiredDeadlineCount,
inProgress:
(statusCounts.applied || 0) +
(statusCounts.interviewed || 0) +
(statusCounts.shortlisted || 0),
},
opportunities: publicOpportunities,
};
}

function sanitizeShareForOwner(share) {
const snapshot = share.snapshot || {};
const opportunities = snapshot.opportunities || [];
const opportunityTitles = opportunities.map((opportunity) => opportunity.title).filter(Boolean);
const token = decryptShareToken(share);
return {
id: share.id,
snapshotType: share.snapshot_type,
expiresAt: share.expires_at,
isActive: share.is_active,
viewCount: share.view_count || 0,
createdAt: share.created_at,
updatedAt: share.updated_at,
hasPasscode: Boolean(share.passcode_hash),
canCopy: Boolean(token),
url: token ? `${getPublicAppUrl()}/share/${token}` : null,
summary: snapshot.summary || { total: 0 },
options: snapshot.options || {},
opportunityTitles,
primaryLabel:
opportunityTitles.length === 1
? opportunityTitles[0]
: opportunityTitles.length > 1
? `${opportunityTitles.length} opportunities`
: null,
};
}

function sanitizeShareForPublic(share) {
return {
id: share.id,
snapshotType: share.snapshot_type,
expiresAt: share.expires_at,
viewCount: share.view_count || 0,
createdAt: share.created_at,
snapshot: share.snapshot,
hasPasscode: Boolean(share.passcode_hash),
};
}

function getPublicAppUrl() {
const configuredUrl =
process.env.PUBLIC_APP_URL ||
process.env.FRONTEND_URL ||
process.env.CLIENT_URL ||
process.env.CORS_ORIGIN;

if (configuredUrl) {
return configuredUrl.split(',')[0].trim().replace(/\/$/, '');
}

return 'http://localhost:3000';
}

module.exports = {
SHARE_FIELD_DEFAULTS,
buildShareSnapshot,
createPasscodeHash,
decryptShareToken,
encryptShareToken,
generateShareToken,
getPublicAppUrl,
hashToken,
isShareExpired,
isShareUnavailable,
resolveExpiresAt,
sanitizeShareForOwner,
sanitizeShareForPublic,
verifyPasscode,
};
Loading
Loading