Skip to content

Commit 9a8c2c3

Browse files
authored
Merge pull request #69 from austenstone/enterprise
MAJOR REFACTOR adding support for multiple orgs
2 parents 789a344 + db3c752 commit 9a8c2c3

File tree

90 files changed

+3374
-2715
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

90 files changed

+3374
-2715
lines changed

backend/__tests__/survey.test.ts

Lines changed: 0 additions & 47 deletions
This file was deleted.

backend/package-lock.json

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"octokit": "^4.0.2",
2626
"sequelize": "^6.37.5",
2727
"smee-client": "^2.0.4",
28-
"update-dotenv": "^1.1.1"
28+
"update-dotenv": "^1.1.1",
29+
"why-is-node-running": "^3.2.1"
2930
},
3031
"devDependencies": {
3132
"@eslint/js": "^9.14.0",

backend/src/app.ts

Lines changed: 164 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,176 @@
11
import 'dotenv/config'
2-
import express from 'express';
2+
import express, { Express } from 'express';
33
import rateLimit from 'express-rate-limit';
44
import bodyParser from 'body-parser';
55
import cors from 'cors';
66
import path, { dirname } from 'path';
7+
import { fileURLToPath } from 'url';
8+
import * as http from 'http';
9+
import { AddressInfo } from 'net';
710
import apiRoutes from "./routes/index.js"
8-
import { dbConnect } from './database.js';
9-
import setup from './services/setup.js';
10-
import settingsService from './services/settings.service.js';
11-
import SmeeService from './services/smee.js';
11+
import Database from './database.js';
1212
import logger, { expressLoggerMiddleware } from './services/logger.js';
13-
import { fileURLToPath } from 'url';
13+
import GitHub from './github.js';
14+
import WebhookService from './services/smee.js';
15+
import SettingsService from './services/settings.service.js';
16+
import whyIsNodeRunning from 'why-is-node-running';
17+
18+
class App {
19+
eListener?: http.Server;
20+
baseUrl?: string;
1421

15-
const PORT = Number(process.env.PORT) || 80;
16-
17-
export const app = express();
18-
app.use(cors());
19-
app.use(expressLoggerMiddleware);
20-
21-
(async () => {
22-
await dbConnect();
23-
logger.info('DB Connected ✅');
24-
await settingsService.initializeSettings();
25-
logger.info('Settings loaded ✅');
26-
await SmeeService.createSmeeWebhookProxy(PORT);
27-
logger.info('Created Smee webhook proxy ✅');
28-
29-
try {
30-
await setup.createAppFromEnv();
31-
logger.info('Created GitHub App from environment ✅');
32-
} catch (error) {
33-
logger.info('Failed to create app from environment. This is expected if the app is not yet installed.', error);
22+
constructor(
23+
public e: Express,
24+
public port: number,
25+
public database: Database,
26+
public github: GitHub,
27+
public settingsService: SettingsService
28+
) {
29+
this.e = e;
30+
this.port = port;
3431
}
3532

36-
app.use((req, res, next) => {
37-
if (req.path === '/api/github/webhooks') {
38-
return next();
39-
}
40-
bodyParser.json()(req, res, next);
41-
}, bodyParser.urlencoded({ extended: true }));
42-
app.use('/api', apiRoutes);
43-
44-
const __filename = fileURLToPath(import.meta.url);
45-
const __dirname = dirname(__filename);
46-
const frontendPath = path.resolve(__dirname, '../../frontend/dist/github-value/browser');
47-
48-
app.use(express.static(frontendPath));
49-
app.get('*', rateLimit({
50-
windowMs: 15 * 60 * 1000, max: 5000,
51-
}), (_, res) => res.sendFile(path.join(frontendPath, 'index.html')));
52-
53-
app.listen(PORT, () => {
54-
logger.info(`Server is running at http://localhost:${PORT} 🚀`);
55-
if (process.env.WEB_URL) {
56-
logger.debug(`Frontend is running at ${process.env.WEB_URL} 🚀`);
33+
public async start() {
34+
try {
35+
this.setupExpress();
36+
await this.database.connect();
37+
38+
await this.initializeSettings();
39+
logger.info('Settings initialized');
40+
41+
await this.github.connect();
42+
logger.info('Created GitHub App from environment');
43+
44+
return this.e;
45+
} catch (error) {
46+
await this.github.smee.connect();
47+
logger.debug(error);
48+
logger.error('Failed to start application ❌');
49+
if (error instanceof Error) {
50+
logger.error(error.message);
51+
}
5752
}
53+
}
54+
55+
public stop() {
56+
whyIsNodeRunning()
57+
this.database.disconnect();
58+
this.github.disconnect();
59+
this.eListener?.close(() => {
60+
logger.info('Server closed');
61+
process.exit(0);
62+
});
63+
}
64+
65+
private setupExpress() {
66+
this.e.use(cors());
67+
this.e.use(expressLoggerMiddleware);
68+
this.e.use((req, res, next) => {
69+
if (req.path === '/api/github/webhooks') {
70+
return next();
71+
}
72+
bodyParser.json()(req, res, next);
73+
}, bodyParser.urlencoded({ extended: true }));
74+
75+
this.e.use('/api', apiRoutes);
76+
77+
const __filename = fileURLToPath(import.meta.url);
78+
const __dirname = dirname(__filename);
79+
const frontendPath = path.resolve(__dirname, '../../frontend/dist/github-value/browser');
80+
this.e.use(express.static(frontendPath));
81+
this.e.get('*', rateLimit({
82+
windowMs: 15 * 60 * 1000, max: 5000,
83+
}), (_, res) => res.sendFile(path.join(frontendPath, 'index.html')));
84+
85+
const listener = this.e.listen(this.port, () => {
86+
const address = listener.address() as AddressInfo;
87+
logger.info(`Server is running at http://${address.address === '::' ? 'localhost' : address.address}:${address.port} 🚀`);
88+
});
89+
this.eListener = listener;
90+
}
91+
92+
private initializeSettings() {
93+
this.settingsService.initialize()
94+
.then(async (settings) => {
95+
if (settings.webhookProxyUrl) {
96+
this.github.smee.options.url = settings.webhookProxyUrl
97+
}
98+
if (settings.webhookSecret) {
99+
this.github.setInput({
100+
webhooks: {
101+
secret: settings.webhookSecret
102+
}
103+
});
104+
}
105+
if (settings.metricsCronExpression) {
106+
this.github.cronExpression = settings.metricsCronExpression;
107+
}
108+
if (settings.baseUrl) {
109+
this.baseUrl = settings.baseUrl;
110+
}
111+
})
112+
.finally(async () => {
113+
await this.github.smee.connect()
114+
await this.settingsService.updateSetting('webhookSecret', this.github.input.webhooks?.secret || '');
115+
await this.settingsService.updateSetting('webhookProxyUrl', this.github.smee.options.url!);
116+
await this.settingsService.updateSetting('metricsCronExpression', this.github.cronExpression!);
117+
});
118+
}
119+
}
120+
121+
const port = Number(process.env.PORT) || 80;
122+
const e = express();
123+
const app = new App(
124+
e,
125+
port,
126+
new Database({
127+
dialect: 'mysql',
128+
logging: (sql) => logger.debug(sql),
129+
timezone: '+00:00', // Force UTC timezone
130+
dialectOptions: {
131+
timezone: '+00:00' // Force UTC for MySQL connection
132+
},
133+
host: process.env.MYSQL_HOST,
134+
port: Number(process.env.MYSQL_PORT) || 3306,
135+
username: process.env.MYSQL_USER,
136+
password: process.env.MYSQL_PASSWORD,
137+
database: process.env.MYSQL_DATABASE || 'value'
138+
}),
139+
new GitHub(
140+
{
141+
appId: process.env.GITHUB_APP_ID,
142+
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
143+
webhooks: {
144+
secret: process.env.GITHUB_WEBHOOK_SECRET
145+
}
146+
},
147+
e,
148+
new WebhookService({
149+
url: process.env.WEBHOOK_PROXY_URL,
150+
path: '/api/github/webhooks',
151+
port
152+
})
153+
), new SettingsService({
154+
baseUrl: process.env.BASE_URL,
155+
webhookProxyUrl: process.env.GITHUB_WEBHOOK_PROXY_URL,
156+
webhookSecret: process.env.GITHUB_WEBHOOK_SECRET,
157+
metricsCronExpression: '0 0 * * *',
158+
devCostPerYear: '100000',
159+
developerCount: '100',
160+
hoursPerYear: '2080',
161+
percentTimeSaved: '20',
162+
percentCoding: '20'
163+
})
164+
);
165+
app.start();
166+
logger.info('App started');
167+
168+
export default app;
169+
170+
['SIGTERM', 'SIGINT', 'SIGQUIT'].forEach(signal => {
171+
process.on(signal, () => {
172+
logger.info(`Received ${signal}. Stopping the app...`);
173+
app.stop();
174+
process.exit(signal === 'uncaughtException' ? 1 : 0);
58175
});
59-
})();
176+
});

backend/src/controllers/metrics.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import MetricsService from '../services/metrics.service.js';
44
class MetricsController {
55
async getMetrics(req: Request, res: Response): Promise<void> {
66
try {
7-
const metrics = await MetricsService.queryMetrics(req.query)
7+
const metrics = await MetricsService.getMetrics(req.query)
88
res.status(200).json(metrics);
99
} catch (error) {
1010
res.status(500).json(error);
@@ -13,7 +13,7 @@ class MetricsController {
1313

1414
async getMetricsTotals(req: Request, res: Response): Promise<void> {
1515
try {
16-
const metrics = await MetricsService.queryMetricsTotals(req.query)
16+
const metrics = await MetricsService.getMetricsTotals(req.query)
1717
res.status(200).json(metrics);
1818
} catch (error) {
1919
res.status(500).json(error);

backend/src/controllers/seats.controller.ts

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import SeatsService from '../services/copilot.seats.service.js';
33

44
class SeatsController {
55
async getAllSeats(req: Request, res: Response): Promise<void> {
6+
const org = req.query.org?.toString()
67
try {
7-
const seats = await SeatsService.getAllSeats();
8+
const seats = await SeatsService.getAllSeats(org);
89
res.status(200).json(seats);
910
} catch (error) {
1011
res.status(500).json(error);
@@ -23,48 +24,30 @@ class SeatsController {
2324
}
2425

2526
async getActivity(req: Request, res: Response): Promise<void> {
27+
const org = req.query.org?.toString()
2628
const { daysInactive, precision } = req.query;
2729
const _daysInactive = Number(daysInactive);
2830
if (!daysInactive || isNaN(_daysInactive)) {
2931
res.status(400).json({ error: 'daysInactive query parameter is required' });
3032
return;
3133
}
3234
try {
33-
const activityDays = await SeatsService.getAssigneesActivity(_daysInactive, precision as 'hour' | 'day' | 'minute');
35+
const activityDays = await SeatsService.getMembersActivity(org, _daysInactive, precision as 'hour' | 'day' | 'minute');
3436
res.status(200).json(activityDays);
3537
} catch (error) {
3638
res.status(500).json(error);
3739
}
3840
}
3941

4042
async getActivityTotals(req: Request, res: Response): Promise<void> {
43+
const org = req.query.org?.toString()
4144
try {
42-
const totals = await SeatsService.getAssigneesActivityTotals();
45+
const totals = await SeatsService.getMembersActivityTotals(org);
4346
res.status(200).json(totals);
4447
} catch (error) {
4548
res.status(500).json(error);
4649
}
4750
}
48-
49-
async getActivityHighcharts(req: Request, res: Response): Promise<void> {
50-
try {
51-
const { daysInactive } = req.query;
52-
const _daysInactive = Number(daysInactive);
53-
if (!daysInactive || isNaN(_daysInactive)) {
54-
res.status(400).json({ error: 'daysInactive query parameter is required' });
55-
return;
56-
}
57-
const activityDays = await SeatsService.getAssigneesActivity(_daysInactive);
58-
const activeData = Object.entries(activityDays).reduce((acc, [date, data]) => {
59-
acc.push([new Date(date).getTime(), data.totalActive]);
60-
return acc;
61-
}, [] as [number, number][]);
62-
res.status(200).json(activeData);
63-
} catch (error) {
64-
res.status(500).json(error);
65-
}
66-
}
67-
6851
}
6952

7053
export default new SeatsController();

0 commit comments

Comments
 (0)