Skip to content

Commit c2f8ea1

Browse files
committed
add workspace invites
1 parent 21ece18 commit c2f8ea1

10 files changed

+256
-24
lines changed

config.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use strict';
2+
3+
if (!process.env.NODE_ENV) {
4+
process.env.NODE_ENV = 'local';
5+
}
6+
const env = process.env.NODE_ENV;
7+
8+
const dotenv = require('dotenv');
9+
const path = require('path');
10+
11+
console.log('NODE_ENV =', env);
12+
13+
if (!['development', 'production'].includes(process.env.NODE_ENV)) {
14+
dotenv.config({
15+
path: path.resolve(__dirname, `.env.${env.toLocaleLowerCase()}`)
16+
});
17+
}

netlify/functions/getWorkspace.js

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,3 @@
11
'use strict';
22

3-
const Archetype = require('archetype');
4-
const connect = require('../../src/db');
5-
const extrovert = require('extrovert');
6-
7-
const GetWorkspaceParams = new Archetype({
8-
apiKey: {
9-
$type: 'string',
10-
$required: true
11-
}
12-
}).compile('GetWorkspaceParams');
13-
14-
module.exports = extrovert.toNetlifyFunction(async function getWorkspace(params) {
15-
const { apiKey } = new GetWorkspaceParams(params);
16-
17-
const db = await connect();
18-
const { Workspace } = db.models;
19-
20-
const workspace = await Workspace.findOne({ apiKey }).orFail();
21-
return { workspace };
22-
});
3+
module.exports = extrovert.toNetlifyFunction(require('../../src/actions/getWorkspace'));

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"cheerio": "1.0.0",
1212
"extrovert": "0.0.26",
1313
"mongoose": "8.x",
14-
"ramda": "0.28.0"
14+
"ramda": "0.28.0",
15+
"time-commando": "1.0.1"
1516
},
1617
"devDependencies": {
1718
"@masteringjs/eslint-config": "0.1.1",
@@ -27,7 +28,7 @@
2728
"scripts": {
2829
"seed": "env NODE_ENV=development node ./seed",
2930
"start": "node .",
30-
"test": "env NODE_ENV=test mocha test/*.test.js",
31+
"test": "env NODE_ENV=test mocha -r ./config test/*.test.js",
3132
"lint": "eslint ."
3233
}
3334
}

src/actions/getWorkspace.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use strict';
2+
3+
const Archetype = require('archetype');
4+
const connect = require('../../src/db');
5+
const extrovert = require('extrovert');
6+
7+
const GetWorkspaceParams = new Archetype({
8+
apiKey: {
9+
$type: 'string',
10+
$required: true
11+
}
12+
}).compile('GetWorkspaceParams');
13+
14+
module.exports = async function getWorkspace(params) {
15+
const { apiKey } = new GetWorkspaceParams(params);
16+
17+
const db = await connect();
18+
const { Workspace } = db.models;
19+
20+
const workspace = await Workspace.findOne({ apiKey }).orFail();
21+
return { workspace };
22+
};

src/actions/inviteToWorkspace.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use strict';
2+
3+
const Archetype = require('archetype');
4+
const connect = require('../../src/db');
5+
const extrovert = require('extrovert');
6+
const mongoose = require('mongoose');
7+
8+
const InviteToWorkspaceParams = new Archetype({
9+
authorization: {
10+
$type: 'string',
11+
$required: true
12+
},
13+
workspaceId: {
14+
$type: mongoose.Types.ObjectId,
15+
$required: true
16+
},
17+
githubUsername: {
18+
$type: 'string',
19+
$required: true
20+
},
21+
roles: {
22+
$type: ['string'],
23+
$required: true,
24+
$enum: ['admin', 'member', 'readonly', 'dashboards']
25+
}
26+
}).compile('InviteToWorkspaceParams');
27+
28+
module.exports = async function inviteToWorkspace(params) {
29+
const { authorization, githubUsername, roles, workspaceId } = new InviteToWorkspaceParams(params);
30+
31+
const db = await connect();
32+
const { AccessToken, User, Workspace, Invitation } = db.models;
33+
34+
// Verify access token
35+
const accessToken = await AccessToken.findById(authorization).orFail(new Error('Invalid or expired access token'));
36+
if (accessToken.expiresAt < new Date()) {
37+
throw new Error('Access token has expired');
38+
}
39+
40+
const invitedByUserId = accessToken.userId;
41+
42+
// Find a workspace where the user is an owner or admin
43+
const workspace = await Workspace.findById(workspaceId).orFail();
44+
const inviterRoles = workspace.members.find(member => member.userId.toString() === invitedByUserId.toString())?.roles;
45+
if (inviterRoles == null || (!inviterRoles.includes('admin') && !inviterRoles.includes('owner'))) {
46+
throw new Error('Forbidden');
47+
}
48+
49+
const isAlreadyMember = await User.exists(
50+
{ _id: { $in: workspace.members.map(member => member.userId) },
51+
githubUsername
52+
});
53+
if (isAlreadyMember) {
54+
throw new Error(`${githubUsername} is already a member of this workspace`);
55+
}
56+
57+
// Create or update an invitation
58+
const invitation = await Invitation.findOneAndUpdate(
59+
{ workspaceId: workspace._id, githubUsername },
60+
{ invitedBy: invitedByUserId, roles, status: 'pending' },
61+
{ upsert: true, new: true }
62+
);
63+
64+
return { invitation };
65+
};

src/db/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const mongoose = require('mongoose');
55
let conn = null;
66

77
const accessTokenSchema = require('./AccessToken');
8+
const invitationSchema = require('./invitation');
89
const jobSchema = require('./Job');
910
const subscriberSchema = require('./subscriber');
1011
const taskSchema = require('./task');
@@ -19,6 +20,7 @@ module.exports = async function connect() {
1920
await conn.asPromise();
2021
}
2122
conn.model('AccessToken', accessTokenSchema, 'AccessToken');
23+
conn.model('Invitation', invitationSchema, 'Invitation');
2224
conn.model('Job', jobSchema, 'Job');
2325
conn.model('Subscriber', subscriberSchema, 'Subscriber');
2426
conn.model('Task', taskSchema, 'Task');

src/db/invitation.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use strict';
2+
3+
const mongoose = require('mongoose');
4+
const time = require('time-commando');
5+
6+
const invitationSchema = new mongoose.Schema({
7+
workspaceId: {
8+
type: mongoose.Schema.Types.ObjectId,
9+
ref: 'Workspace',
10+
required: true
11+
},
12+
githubUsername: {
13+
type: String,
14+
required: true
15+
},
16+
invitedBy: {
17+
type: mongoose.Schema.Types.ObjectId,
18+
ref: 'User',
19+
required: true
20+
},
21+
roles: [{
22+
type: String,
23+
required: true,
24+
enum: ['admin', 'member', 'readonly', 'dashboards']
25+
}],
26+
status: {
27+
type: String,
28+
enum: ['pending', 'accepted', 'declined', 'expired'],
29+
default: 'pending'
30+
},
31+
expiresAt: {
32+
type: Date,
33+
default: () => time.now() + 7 * time.oneDayMS
34+
}
35+
}, { timestamps: true, id: false });
36+
37+
module.exports = invitationSchema;

src/db/workspace.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ const workspaceSchema = new mongoose.Schema({
3333
baseUrl: {
3434
type: String,
3535
required: true
36+
},
37+
stripeCustomerId: {
38+
type: String,
39+
unique: true,
40+
sparse: true
41+
},
42+
stripeSubscriptionId: {
43+
type: String,
44+
unique: true,
45+
sparse: true
46+
},
47+
subscriptionTier: {
48+
type: String,
49+
enum: ['pro'],
50+
required: true
3651
}
3752
}, { timestamps: true, id: false });
3853

test/inviteToWorkspace.test.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use strict';
2+
3+
const assert = require('assert');
4+
const mongoose = require('mongoose');
5+
const inviteToWorkspace = require('../src/actions/inviteToWorkspace');
6+
const connect = require('../src/db');
7+
8+
describe('inviteToWorkspace', function () {
9+
let db, AccessToken, User, Workspace, Invitation;
10+
let user, workspace, accessToken;
11+
12+
beforeEach(async function () {
13+
// Connect to the real database
14+
db = await connect();
15+
({ AccessToken, User, Workspace, Invitation } = db.models);
16+
17+
await AccessToken.deleteMany({});
18+
await User.deleteMany({});
19+
await Workspace.deleteMany({});
20+
await Invitation.deleteMany({});
21+
22+
user = await User.create({
23+
name: 'John Doe',
24+
25+
githubUsername: 'johndoe',
26+
githubUserId: '1234'
27+
});
28+
29+
accessToken = await AccessToken.create({
30+
userId: user._id,
31+
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) // 30 days valid
32+
});
33+
34+
workspace = await Workspace.create({
35+
name: 'Test Workspace',
36+
ownerId: user._id,
37+
apiKey: 'test-api-key',
38+
baseUrl: 'https://example.com',
39+
members: [{ userId: user._id, roles: ['owner'] }],
40+
subscriptionTier: 'pro'
41+
});
42+
});
43+
44+
afterEach(async function () {
45+
// Cleanup after tests
46+
await AccessToken.deleteMany({});
47+
await User.deleteMany({});
48+
await Workspace.deleteMany({});
49+
await Invitation.deleteMany({});
50+
});
51+
52+
it('should invite a user successfully', async function () {
53+
const result = await inviteToWorkspace({
54+
authorization: accessToken._id.toString(),
55+
workspaceId: workspace._id,
56+
githubUsername: 'janesmith',
57+
roles: ['member']
58+
});
59+
60+
assert.strictEqual(result.invitation.githubUsername, 'janesmith');
61+
assert.strictEqual(result.invitation.status, 'pending');
62+
assert.deepStrictEqual(result.invitation.roles, ['member']);
63+
64+
const invitationInDb = await Invitation.findOne({ githubUsername: 'janesmith' });
65+
assert.ok(invitationInDb, 'Invitation should be saved in the database');
66+
assert.strictEqual(invitationInDb.invitedBy.toString(), user._id.toString());
67+
assert.strictEqual(invitationInDb.workspaceId.toString(), workspace._id.toString());
68+
});
69+
70+
71+
it('should fail if user is already a member of the workspace', async function () {
72+
const invitedUser = await User.create({
73+
name: 'Jane Smith',
74+
75+
githubUsername: 'janesmith'
76+
});
77+
78+
// Add the user directly as a member of the workspace
79+
await Workspace.findByIdAndUpdate(workspace._id, {
80+
$push: { members: { userId: invitedUser._id, roles: ['member'] } }
81+
});
82+
83+
await assert.rejects(
84+
inviteToWorkspace({
85+
authorization: accessToken._id.toString(),
86+
workspaceId: workspace._id,
87+
githubUsername: 'janesmith',
88+
roles: ['member']
89+
}),
90+
/janesmith is already a member of this workspace/
91+
);
92+
});
93+
});
94+

test/setup.test.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
'use strict';
22

3-
require('dotenv').config({ path: './.env.test' });
4-
53
const { after, before } = require('mocha');
64
const connect = require('../src/db');
75

0 commit comments

Comments
 (0)