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
22 changes: 19 additions & 3 deletions shatter-backend/src/controllers/event_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pusher } from "../utils/pusher_websocket";
import "../models/participant_model";

import { generateJoinCode } from "../utils/event_utils";
import { generateToken } from "../utils/jwt_utils";
import { Participant } from "../models/participant_model";
import { User } from "../models/user_model";
import { Types } from "mongoose";
Expand Down Expand Up @@ -247,20 +248,33 @@ export async function joinEventAsGuest(req: Request, res: Response) {
return res.status(400).json({ success: false, msg: "Event is full" });
}

// Create guest participant (userId is null)
// Create a guest user account so they get a JWT and can complete their profile later
const user = await User.create({
name,
authProvider: 'guest',
});

const userId = user._id as Types.ObjectId;
const token = generateToken(userId.toString());

// Create participant linked to the new user
const participant = await Participant.create({
userId: null,
userId,
name,
eventId,
});

const participantId = participant._id as Types.ObjectId;

// Add participant to event
// Add participant to event and event to user history
await Event.updateOne(
{ _id: eventId },
{ $addToSet: { participantIds: participantId } },
);
await User.updateOne(
{ _id: userId },
{ $addToSet: { eventHistoryIds: eventId } },
);

// Emit socket
console.log("Room socket:", eventId);
Expand All @@ -278,6 +292,8 @@ export async function joinEventAsGuest(req: Request, res: Response) {
return res.json({
success: true,
participant,
userId,
token,
});
} catch (e: any) {
if (e.code === 11000) {
Expand Down
90 changes: 90 additions & 0 deletions shatter-backend/src/controllers/user_controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Request, Response } from "express";
import { User } from "../models/user_model";
import { hashPassword } from "../utils/password_hash";

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

// controller: GET /api/users
// This function handles GET reqs to /api/users
Expand Down Expand Up @@ -88,3 +91,90 @@ export const getUserEvents = async (req: Request, res: Response) => {
res.status(500).json({ success: false, error: err.message });
}
};

/**
* PUT /api/users/:userId
* Update user profile. Users can only update their own profile.
* Guest users can upgrade to local auth by providing email + password.
*/
export const updateUser = async (req: Request, res: Response) => {
try {
const { userId } = req.params;

// Users can only update their own profile
if (req.user?.userId !== userId) {
return res.status(403).json({ success: false, error: "You can only update your own profile" });
}

const { name, email, password, bio, profilePhoto, socialLinks } = req.body as {
name?: string;
email?: string;
password?: string;
bio?: string;
profilePhoto?: string;
socialLinks?: { linkedin?: string; github?: string; other?: string };
};

const updateFields: Record<string, any> = {};

if (name !== undefined) {
if (!name.trim()) {
return res.status(400).json({ success: false, error: "Name cannot be empty" });
}
updateFields.name = name.trim();
}

if (email !== undefined) {
const normalizedEmail = email.toLowerCase().trim();
if (!EMAIL_REGEX.test(normalizedEmail)) {
return res.status(400).json({ success: false, error: "Invalid email format" });
}
// Check for duplicate email
const existing = await User.findOne({ email: normalizedEmail, _id: { $ne: userId } }).lean();
if (existing) {
return res.status(409).json({ success: false, error: "Email already in use" });
}
updateFields.email = normalizedEmail;
}

if (password !== undefined) {
if (password.length < 8) {
return res.status(400).json({ success: false, error: "Password must be at least 8 characters long" });
}
updateFields.passwordHash = await hashPassword(password);
updateFields.passwordChangedAt = new Date();
// Upgrade guest users to local auth when they set a password
const currentUser = await User.findById(userId).lean();
if (currentUser?.authProvider === 'guest') {
updateFields.authProvider = 'local';
}
}

if (bio !== undefined) updateFields.bio = bio;
if (profilePhoto !== undefined) updateFields.profilePhoto = profilePhoto;
if (socialLinks !== undefined) updateFields.socialLinks = socialLinks;

if (Object.keys(updateFields).length === 0) {
return res.status(400).json({ success: false, error: "No fields to update" });
}

const result = await User.updateOne(
{ _id: userId },
{ $set: updateFields },
);

if (result.matchedCount === 0) {
return res.status(404).json({ success: false, error: "User not found" });
}

const updatedUser = await User.findById(userId).select("-passwordHash");

res.status(200).json({ success: true, user: updatedUser });
} catch (err: any) {
if (err?.code === 11000) {
return res.status(409).json({ success: false, error: "Email already in use" });
}
console.error("PUT /api/users/:userId error:", err);
res.status(500).json({ success: false, error: "Failed to update user" });
}
};
24 changes: 20 additions & 4 deletions shatter-backend/src/models/user_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ import { Schema, model } from "mongoose";

export interface IUser {
name: string;
email: string;
email?: string;
passwordHash?: string;
linkedinId?: string;
linkedinUrl?: string;
bio?: string;
profilePhoto?: string;
authProvider: 'local' | 'linkedin';
socialLinks?: {
linkedin?: string;
github?: string;
other?: string;
};
authProvider: 'local' | 'linkedin' | 'guest';
lastLogin?: Date;
passwordChangedAt?: Date;
createdAt?: Date;
Expand All @@ -34,10 +40,11 @@ const UserSchema = new Schema<IUser>(
},
email: {
type: String,
required: true,
required: false,
trim: true,
lowercase: true,
unique: true,
sparse: true, // allows multiple users without email (guests)
index: true,
match: [
/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/,
Expand All @@ -59,12 +66,21 @@ const UserSchema = new Schema<IUser>(
unique: true,
sparse: true,
},
bio: {
type: String,
trim: true,
},
profilePhoto: {
type: String,
},
socialLinks: {
linkedin: { type: String },
github: { type: String },
other: { type: String },
},
authProvider: {
type: String,
enum: ['local', 'linkedin'],
enum: ['local', 'linkedin', 'guest'],
default: 'local',
required: true,
},
Expand Down
5 changes: 4 additions & 1 deletion shatter-backend/src/routes/user_route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Router, Request, Response } from 'express';
import { getUsers, createUser, getUserById, getUserEvents } from '../controllers/user_controller';
import { getUsers, createUser, getUserById, getUserEvents, updateUser } from '../controllers/user_controller';
import { authMiddleware } from '../middleware/auth_middleware';
import { User } from '../models/user_model';

Expand All @@ -25,6 +25,9 @@ router.get('/me', authMiddleware, async (req: Request, res: Response) => {
// Get all events a user has joined - must come before /:userId to avoid route conflict
router.get('/:userId/events', authMiddleware, getUserEvents);

// Update user profile (protected, self only)
router.put('/:userId', authMiddleware, updateUser);

// Get user by ID
router.get('/:userId', authMiddleware, getUserById);

Expand Down