diff --git a/backend/controllers/experience.controller.js b/backend/controllers/experience.controller.js
index 656e8b2..7472721 100644
--- a/backend/controllers/experience.controller.js
+++ b/backend/controllers/experience.controller.js
@@ -42,33 +42,9 @@ const deleteProject = async (req, res) => {
}
};
-// Experience Text
-const getExpText = async (req, res) => {
- try {
- const userId = req.user.id;
- const data = await experienceService.getExperienceText(userId, req.supabase);
- res.json(data);
- } catch (error) {
- res.status(400).json({ error: error.message });
- }
-};
-
-const putExpText = async (req, res) => {
- try {
- const userId = req.user.id;
- const { id, text } = req.body;
- const data = await experienceService.saveExperienceText(userId, id, text, req.supabase);
- res.json(data);
- } catch (error) {
- res.status(400).json({ error: error.message });
- }
-};
-
module.exports = {
getProjects,
postProject,
putProject,
- deleteProject,
- getExpText,
- putExpText
+ deleteProject
};
\ No newline at end of file
diff --git a/backend/controllers/roles.controller.js b/backend/controllers/roles.controller.js
new file mode 100644
index 0000000..cce4d19
--- /dev/null
+++ b/backend/controllers/roles.controller.js
@@ -0,0 +1,20 @@
+const supabase = require('../supabaseClient');
+
+const getRolesBank = async (req, res) => {
+ try {
+ const { data, error } = await req.supabase
+ .from('roles_dictionary')
+ .select('*')
+ .order('name');
+
+ if (error) throw new Error(error.message);
+
+ res.json(data);
+ } catch (error) {
+ res.status(400).json({ error: error.message });
+ }
+};
+
+module.exports = {
+ getRolesBank
+};
diff --git a/backend/migrations/003_add_experience_tables.sql b/backend/migrations/003_add_experience_tables.sql
new file mode 100644
index 0000000..47c2c56
--- /dev/null
+++ b/backend/migrations/003_add_experience_tables.sql
@@ -0,0 +1,30 @@
+-- Bank of roles and tags
+CREATE TABLE IF NOT EXISTS roles_dictionary (
+ id SERIAL PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL,
+ category TEXT -- e.g., 'role', 'tag'
+);
+
+-- Relational table for tracking user experience
+CREATE TABLE IF NOT EXISTS user_experiences (
+ id SERIAL PRIMARY KEY,
+ user_id UUID NOT NULL, -- Link to user
+ role_id INTEGER REFERENCES roles_dictionary(id) ON DELETE CASCADE,
+ status TEXT CHECK (status IN ('current', 'previous')),
+ years INTEGER, -- Manual override (e.g., 0 for no experience, 1, 2...)
+ start_date DATE, -- Optional: used to dynamically calculate duration
+ end_date DATE -- Optional: marks the end of previous roles
+);
+
+-- Seed initial roles
+INSERT INTO roles_dictionary (name, category) VALUES
+ ('Software Engineer', 'role'),
+ ('Frontend Developer', 'role'),
+ ('Backend Developer', 'role'),
+ ('Fullstack Developer', 'role'),
+ ('DevOps Engineer', 'role'),
+ ('Data Scientist', 'role'),
+ ('Product Manager', 'role'),
+ ('UI', 'tag'),
+ ('UX', 'tag')
+ON CONFLICT (name) DO NOTHING;
diff --git a/backend/repositories/profile.repository.js b/backend/repositories/profile.repository.js
index 988ab75..ead259e 100644
--- a/backend/repositories/profile.repository.js
+++ b/backend/repositories/profile.repository.js
@@ -21,8 +21,33 @@ const createProfile = async (userId, updateData, client) => {
.single();
};
+const findUserExperiences = async (userId, client) => {
+ return await client
+ .from('user_experiences')
+ .select('*, roles_dictionary(*)')
+ .eq('user_id', userId);
+};
+
+const syncUserExperiences = async (userId, experiences, client) => {
+ // Simplified sync: delete all and re-insert
+ await client.from('user_experiences').delete().eq('user_id', userId);
+ if (experiences && experiences.length > 0) {
+ const rows = experiences.map(exp => ({
+ user_id: userId,
+ role_id: exp.role_id,
+ status: exp.status,
+ years: exp.years || null,
+ start_date: exp.start_date || null,
+ end_date: exp.end_date || null
+ }));
+ await client.from('user_experiences').insert(rows);
+ }
+};
+
module.exports = {
findFirstProfile,
updateProfile,
- createProfile
+ createProfile,
+ findUserExperiences,
+ syncUserExperiences
};
\ No newline at end of file
diff --git a/backend/routes/experience.routes.js b/backend/routes/experience.routes.js
index 496dbde..a484567 100644
--- a/backend/routes/experience.routes.js
+++ b/backend/routes/experience.routes.js
@@ -8,9 +8,4 @@ router.get('/projects', authenticate, experienceController.getProjects);
router.post('/projects', authenticate, experienceController.postProject);
router.put('/projects/:id', authenticate, experienceController.putProject);
router.delete('/projects/:id', authenticate, experienceController.deleteProject);
-
-// Text Routes
-router.get('/text', authenticate, experienceController.getExpText);
-router.put('/text', authenticate, experienceController.putExpText);
-
module.exports = router;
\ No newline at end of file
diff --git a/backend/routes/roles.routes.js b/backend/routes/roles.routes.js
new file mode 100644
index 0000000..9de43fc
--- /dev/null
+++ b/backend/routes/roles.routes.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const router = express.Router();
+const rolesController = require('../controllers/roles.controller');
+const { authenticate } = require('../middleware/auth');
+
+router.get('/', authenticate, rolesController.getRolesBank);
+
+module.exports = router;
diff --git a/backend/schema.sql b/backend/schema.sql
index 47e31bf..31ca342 100644
--- a/backend/schema.sql
+++ b/backend/schema.sql
@@ -162,3 +162,21 @@ CREATE TABLE IF NOT EXISTS rss_jobs (
seniority TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
+
+-- Bank of roles and tags
+CREATE TABLE IF NOT EXISTS roles_dictionary (
+ id SERIAL PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL,
+ category TEXT -- e.g., 'role', 'tag'
+);
+
+-- Relational table for tracking user experience
+CREATE TABLE IF NOT EXISTS user_experiences (
+ id SERIAL PRIMARY KEY,
+ user_id UUID NOT NULL, -- Link to user
+ role_id INTEGER REFERENCES roles_dictionary(id) ON DELETE CASCADE,
+ status TEXT CHECK (status IN ('current', 'previous')),
+ years INTEGER, -- Manual override (e.g., 0 for no experience, 1, 2...)
+ start_date DATE, -- Optional: used to dynamically calculate duration
+ end_date DATE -- Optional: marks the end of previous roles
+);
diff --git a/backend/server.js b/backend/server.js
index 2751e2c..65ab5f4 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -30,6 +30,7 @@ const { startMailPolling } = require('./cron/mail-poll-cron');
const notificationsRoutes = require('./routes/notifications.routes');
const rssRoutes = require('./routes/rss.routes');
const { startRssPolling } = require('./cron/rss-poll-cron');
+const rolesRoutes = require('./routes/roles.routes');
// --- Mount Routes ---
app.use('/auth', userRoutes);
@@ -49,6 +50,7 @@ app.use('/api/messages', messagesRoutes);
app.use('/api/events', eventsRoutes);
app.use('/api/notifications', notificationsRoutes);
app.use('/api/rss-feeds', rssRoutes);
+app.use('/api/roles-bank', rolesRoutes);
// Health check
app.get('/api/health', (req, res) => {
diff --git a/backend/services/profile.service.js b/backend/services/profile.service.js
index 9b9273d..514cfe4 100644
--- a/backend/services/profile.service.js
+++ b/backend/services/profile.service.js
@@ -7,6 +7,11 @@ const getProfile = async (userId, supabaseClient) => {
let profile = data || {};
+ const { data: experiencesData, error: expError } = await profileRepository.findUserExperiences(userId, supabaseClient);
+ if (expError) throw new Error(expError.message);
+
+ profile.experiences = experiencesData || [];
+
// Transformation logic
if (profile.cv_data) {
profile.cvData = profile.cv_data;
@@ -20,7 +25,7 @@ const getProfile = async (userId, supabaseClient) => {
};
const upsertProfile = async (userId, payload, supabaseClient) => {
- const { id, ...updateData } = payload;
+ const { id, experiences, ...updateData } = payload;
// Mapping frontend keys to database keys
if ('cvData' in updateData) {
@@ -37,6 +42,14 @@ const upsertProfile = async (userId, payload, supabaseClient) => {
: await profileRepository.createProfile(userId, updateData, supabaseClient);
if (error) throw new Error(error.message);
+
+ if (experiences !== undefined) {
+ if (experiences.filter(exp => exp.status === 'current').length > 1) {
+ throw new Error("Only one current experience is allowed.");
+ }
+ await profileRepository.syncUserExperiences(userId, experiences, supabaseClient);
+ }
+
return data;
};
diff --git a/backend/tests/controllers/experience.controller.test.js b/backend/tests/controllers/experience.controller.test.js
index 5d7a529..4c18b78 100644
--- a/backend/tests/controllers/experience.controller.test.js
+++ b/backend/tests/controllers/experience.controller.test.js
@@ -51,21 +51,4 @@ describe('experience.controller', () => {
});
});
- describe('getExpText', () => {
- it('should return data', async () => {
- const { req, res } = buildReqRes();
- experienceService.getExperienceText.mockResolvedValue({ text: 'exp' });
- await controller.getExpText(req, res);
- expect(res.json).toHaveBeenCalledWith({ text: 'exp' });
- });
- });
-
- describe('putExpText', () => {
- it('should return data', async () => {
- const { req, res } = buildReqRes({ body: { id: 1, text: 'updated' } });
- experienceService.saveExperienceText.mockResolvedValue({ text: 'updated' });
- await controller.putExpText(req, res);
- expect(res.json).toHaveBeenCalledWith({ text: 'updated' });
- });
- });
});
diff --git a/backend/tests/controllers/roles.controller.test.js b/backend/tests/controllers/roles.controller.test.js
new file mode 100644
index 0000000..1446193
--- /dev/null
+++ b/backend/tests/controllers/roles.controller.test.js
@@ -0,0 +1,50 @@
+const rolesController = require('../../controllers/roles.controller');
+
+describe('Roles Controller', () => {
+ let mockReq;
+ let mockRes;
+
+ beforeEach(() => {
+ mockRes = {
+ json: jest.fn(),
+ status: jest.fn().mockReturnThis()
+ };
+ });
+
+ it('should return roles bank successfully (Sunny Day)', async () => {
+ mockReq = {
+ supabase: {
+ from: jest.fn().mockReturnValue({
+ select: jest.fn().mockReturnValue({
+ order: jest.fn().mockResolvedValue({
+ data: [{ id: 1, name: 'Software Engineer' }],
+ error: null
+ })
+ })
+ })
+ }
+ };
+
+ await rolesController.getRolesBank(mockReq, mockRes);
+ expect(mockRes.json).toHaveBeenCalledWith([{ id: 1, name: 'Software Engineer' }]);
+ });
+
+ it('should handle db error gracefully (Rainy Day)', async () => {
+ mockReq = {
+ supabase: {
+ from: jest.fn().mockReturnValue({
+ select: jest.fn().mockReturnValue({
+ order: jest.fn().mockResolvedValue({
+ data: null,
+ error: { message: 'Database error' }
+ })
+ })
+ })
+ }
+ };
+
+ await rolesController.getRolesBank(mockReq, mockRes);
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith({ error: 'Database error' });
+ });
+});
diff --git a/backend/tests/services/profile.service.test.js b/backend/tests/services/profile.service.test.js
index 9c004ba..38adc7c 100644
--- a/backend/tests/services/profile.service.test.js
+++ b/backend/tests/services/profile.service.test.js
@@ -14,6 +14,10 @@ describe('profile.service', () => {
data: { id: 1, cv_data: { summary: 'My CV' }, website: null },
error: null,
});
+ profileRepository.findUserExperiences.mockResolvedValue({
+ data: [],
+ error: null,
+ });
// Act
const result = await profileService.getProfile('user-123');
@@ -29,6 +33,10 @@ describe('profile.service', () => {
data: { id: 1, website: 'github.com/user' },
error: null,
});
+ profileRepository.findUserExperiences.mockResolvedValue({
+ data: [],
+ error: null,
+ });
// Act
const result = await profileService.getProfile('user-123');
@@ -43,12 +51,16 @@ describe('profile.service', () => {
data: null,
error: { code: 'PGRST116', message: 'Not found' },
});
+ profileRepository.findUserExperiences.mockResolvedValue({
+ data: [],
+ error: null,
+ });
// Act
const result = await profileService.getProfile('user-123');
// Assert
- expect(result).toEqual({});
+ expect(result).toEqual({ experiences: [] });
});
it('should throw on non-PGRST116 error', async () => {
@@ -61,6 +73,24 @@ describe('profile.service', () => {
// Act & Assert
await expect(profileService.getProfile('user-123')).rejects.toThrow('real error');
});
+
+ it('should fetch and attach user experiences', async () => {
+ // Arrange
+ profileRepository.findFirstProfile.mockResolvedValue({
+ data: { id: 1 },
+ error: null,
+ });
+ profileRepository.findUserExperiences.mockResolvedValue({
+ data: [{ id: 1, status: 'current', years: 2 }],
+ error: null,
+ });
+
+ // Act
+ const result = await profileService.getProfile('user-123');
+
+ // Assert
+ expect(result.experiences).toEqual([{ id: 1, status: 'current', years: 2 }]);
+ });
});
describe('upsertProfile', () => {
@@ -69,6 +99,7 @@ describe('profile.service', () => {
profileRepository.updateProfile.mockResolvedValue({
data: { id: 1 }, error: null,
});
+ profileRepository.syncUserExperiences.mockResolvedValue();
// Act
await profileService.upsertProfile('user-123', {
@@ -81,6 +112,45 @@ describe('profile.service', () => {
expect.objectContaining({ cv_data: { summary: 'test' }, website: 'gh.com/me' }),
undefined
);
+ expect(profileRepository.syncUserExperiences).not.toHaveBeenCalled();
+ });
+
+ it('should call syncUserExperiences when experiences array is provided', async () => {
+ // Arrange
+ profileRepository.updateProfile.mockResolvedValue({
+ data: { id: 1 }, error: null,
+ });
+ profileRepository.syncUserExperiences.mockResolvedValue();
+
+ // Act
+ await profileService.upsertProfile('user-123', {
+ id: 1, experiences: [{ role_id: 1, status: 'current' }],
+ });
+
+ // Assert
+ expect(profileRepository.syncUserExperiences).toHaveBeenCalledWith(
+ 'user-123',
+ [{ role_id: 1, status: 'current' }],
+ undefined
+ );
+ });
+
+ it('should throw error if multiple current experiences are provided', async () => {
+ // Arrange
+ profileRepository.updateProfile.mockResolvedValue({
+ data: { id: 1 }, error: null,
+ });
+
+ // Act & Assert
+ await expect(profileService.upsertProfile('user-123', {
+ id: 1,
+ experiences: [
+ { role_id: 1, status: 'current' },
+ { role_id: 2, status: 'current' }
+ ]
+ })).rejects.toThrow('Only one current experience is allowed.');
+
+ expect(profileRepository.syncUserExperiences).not.toHaveBeenCalled();
});
it('should call createProfile when no id', async () => {
diff --git a/frontend/src/components/ConfirmProvider.jsx b/frontend/src/components/ConfirmProvider.jsx
index 52f7da8..8fcd7e7 100644
--- a/frontend/src/components/ConfirmProvider.jsx
+++ b/frontend/src/components/ConfirmProvider.jsx
@@ -11,25 +11,28 @@ export const ConfirmProvider = ({ children }) => {
resolve: null,
});
- const confirm = useCallback((message) => {
+ const confirm = useCallback((message, options = {}) => {
return new Promise((resolve) => {
setConfirmState({
isOpen: true,
message,
resolve,
+ options
});
});
}, []);
- const handleConfirm = useCallback(() => {
- if (confirmState.resolve) confirmState.resolve(true);
- setConfirmState({ isOpen: false, message: '', resolve: null });
+ const handleAction = useCallback((value) => {
+ if (confirmState.resolve) confirmState.resolve(value);
+ setConfirmState({ isOpen: false, message: '', resolve: null, options: {} });
}, [confirmState]);
- const handleCancel = useCallback(() => {
- if (confirmState.resolve) confirmState.resolve(false);
- setConfirmState({ isOpen: false, message: '', resolve: null });
- }, [confirmState]);
+ const defaultButtons = [
+ { text: 'Cancel', value: false, style: 'secondary' },
+ { text: 'Confirm', value: true, style: 'primary' }
+ ];
+
+ const buttons = confirmState.options?.buttons || defaultButtons;
return (
{experienceText}
-