Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,9 @@ EZPAY_TRANSPORT_API_KEY=transport_api_key_here
PRIMARY_SCHOOL_TEXTBOOK_GRANT_PAYMENT_CODE=EDU001
PRIMARY_SCHOOL_TEXTBOOK_GRANT_ADMIN_EMAIL=education@gov.bb


GOOGLE_SHEETS_CLIENT_EMAIL=PASTE_VALUE_HERE
GOOGLE_SHEETS_CREDENTIALS=PASTE_VALUE_HERE

AWS_SES_ENDPOINT=PASTE_VALUE_HERE
AWS_SES_FROM_EMAIL=PASTE_VALUE_HERE
1,238 changes: 1,064 additions & 174 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.954.0",
"@aws-sdk/client-secrets-manager": "^3.964.0",
"@aws-sdk/client-ses": "^3.933.0",
"@aws-sdk/client-sesv2": "^3.933.0",
"@aws-sdk/lib-storage": "^3.954.0",
Expand All @@ -41,6 +42,7 @@
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"googleapis": "^169.0.0",
"handlebars": "^4.7.8",
"pg": "^8.16.3",
"reflect-metadata": "^0.1.13",
Expand Down
25 changes: 25 additions & 0 deletions schemas/exit-survey.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,31 @@
"subject": "New Exit Survey Submission - {{formData.difficultyRating}} Experience",
"template": "exit-survey"
}
},
{
"type": "google-sheets",
"config": {
"spreadsheetId": "{{db:exit-survey:spreadsheet_id}}",
"sheetName": "Responses",
"fields": [
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if we do something like this

Suggested change
"fields": [
"fields": [
{
"title": "Timestamp",
"key": "_timestamp"
}
]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind that. Do you want to submit an update?

"_timestamp",
"_submissionId",
"difficultyRating",
"clarityRating",
"technicalProblems",
"technicalProblemsDescription",
"areasForImprovement"
],
"headers": [
"Timestamp",
"Submission ID",
"Difficulty Rating",
"Clarity Rating",
"Technical Problems",
"Technical Problems Description",
"Areas for Improvement"
]
}
}
]
}
37 changes: 37 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,43 @@ export default () => ({
forms: {
schemasDir: process.env.FORM_SCHEMAS_DIR || 'schemas',
},
googleSheets: {
awsSecretName:
process.env.GOOGLE_SHEETS_AWS_SECRET_NAME || 'google-service-account-key',
...(() => {
// Support full service account JSON via GOOGLE_SHEETS_CREDENTIALS
// Can be raw JSON or base64-encoded JSON
// Set using: cat service-account.json | jq -c . | base64
const credentialsEnv = process.env.GOOGLE_SHEETS_CREDENTIALS;
if (credentialsEnv) {
try {
let credentialsJson = credentialsEnv;

// Check if it's base64-encoded (doesn't start with '{')
if (!credentialsEnv.trim().startsWith('{')) {
credentialsJson = Buffer.from(credentialsEnv, 'base64').toString(
'utf-8',
);
}

const credentials = JSON.parse(credentialsJson);
return {
clientEmail: credentials.client_email,
privateKey: credentials.private_key,
};
} catch (e) {
console.error('Failed to parse GOOGLE_SHEETS_CREDENTIALS:', e);
}
}

// Fallback to separate environment variables
const key = process.env.GOOGLE_SHEETS_PRIVATE_KEY;
return {
clientEmail: process.env.GOOGLE_SHEETS_CLIENT_EMAIL,
privateKey: key ? key.replace(/\\n/g, '\n') : undefined,
};
})(),
},
ezpay: {
apiKey: process.env.EZPAY_API_KEY || 'HWqgTn5EXIHLAzVjXtGpB2mIjgQgj0Ql', // Default API key for backward compatibility
baseUrl: process.env.EZPAY_BASE_URL || 'https://test.ezpay.gov.bb',
Expand Down
8 changes: 8 additions & 0 deletions src/google-sheets/google-sheets.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { GoogleSheetsService } from './google-sheets.service';

@Module({
providers: [GoogleSheetsService],
exports: [GoogleSheetsService],
})
export class GoogleSheetsModule {}
230 changes: 230 additions & 0 deletions src/google-sheets/google-sheets.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { google, sheets_v4 } from 'googleapis';
import { JWT } from 'google-auth-library';
import {
SecretsManagerClient,
GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';

export type GoogleSheetsAppendConfig = {
spreadsheetId: string;
sheetName?: string;
values: (string | number | boolean | null | undefined)[];
};

type GoogleServiceAccountCredentials = {
client_email: string;
private_key: string;
};

@Injectable()
export class GoogleSheetsService {
private readonly logger = new Logger(GoogleSheetsService.name);
private sheets: sheets_v4.Sheets | null = null;
private cachedCredentials: GoogleServiceAccountCredentials | null = null;

constructor(private readonly configService: ConfigService) {}

/**
* Fetch Google service account credentials from AWS Secrets Manager.
* Used in production environments where credentials are stored securely in AWS.
*/
private async getCredentialsFromSecretsManager(): Promise<GoogleServiceAccountCredentials> {
if (this.cachedCredentials) {
return this.cachedCredentials;
}

const region = this.configService.get<string>('aws.region') || 'us-east-1';
const secretName = this.configService.get<string>(
'googleSheets.awsSecretName',
);
const client = new SecretsManagerClient({ region });

this.logger.log(
`Fetching Google Sheets credentials from AWS Secrets Manager (secret: ${secretName})`,
);

const response = await client.send(
new GetSecretValueCommand({
SecretId: secretName,
VersionStage: 'AWSCURRENT',
}),
);

if (!response.SecretString) {
throw new Error(
`Secret ${secretName} not found or has no string value`,
);
}

const credentials = JSON.parse(
response.SecretString,
) as GoogleServiceAccountCredentials;

if (!credentials.client_email || !credentials.private_key) {
throw new Error(
`Secret ${secretName} is missing required fields: client_email and/or private_key`,
);
}

this.cachedCredentials = credentials;
return credentials;
}

/**
* Get credentials from environment variables (local development).
*/
private getCredentialsFromEnv(): GoogleServiceAccountCredentials | null {
const clientEmail = this.configService.get<string>(
'googleSheets.clientEmail',
);
const privateKey = this.configService.get<string>(
'googleSheets.privateKey',
);

if (!clientEmail || !privateKey) {
return null;
}

return {
client_email: clientEmail,
private_key: privateKey,
};
}

/**
* Initialize the Google Sheets client with service account credentials.
* In production (NODE_ENV=production), credentials are fetched from AWS Secrets Manager.
* In development, credentials are loaded from environment variables.
*/
private async getClient(): Promise<sheets_v4.Sheets> {
if (this.sheets) {
return this.sheets;
}

const isProduction =
this.configService.get<string>('app.nodeEnv') === 'production';

let credentials: GoogleServiceAccountCredentials;

if (isProduction) {
// Production: Fetch credentials from AWS Secrets Manager
credentials = await this.getCredentialsFromSecretsManager();
} else {
// Development: Use environment variables
const envCredentials = this.getCredentialsFromEnv();
if (!envCredentials) {
throw new Error(
'Google Sheets credentials not configured. Set GOOGLE_SHEETS_CLIENT_EMAIL and GOOGLE_SHEETS_PRIVATE_KEY environment variables.',
);
}
credentials = envCredentials;
}

const auth = new JWT({
email: credentials.client_email,
key: credentials.private_key,
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});

this.sheets = google.sheets({ version: 'v4', auth });
return this.sheets;
}

/**
* Append a row of values to a Google Sheet
*/
async appendRow(config: GoogleSheetsAppendConfig): Promise<void> {
const { spreadsheetId, sheetName = 'Sheet1', values } = config;

this.logger.log(
`Appending row to spreadsheet ${spreadsheetId}, sheet: ${sheetName}`,
);

const sheets = await this.getClient();

// Convert all values to strings for the API, handling null/undefined
const stringValues = values.map((v) => {
if (v === null || v === undefined) return '';
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
return String(v);
});

const response = await sheets.spreadsheets.values.append({
spreadsheetId,
range: `${sheetName}!A:Z`,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: {
values: [stringValues],
},
});

this.logger.log(
`Row appended successfully. Updated range: ${response.data.updates?.updatedRange}`,
);
}

/**
* Append a row to a Google Sheet with column headers.
* On first submission, adds headers if the sheet is empty.
*/
async appendRowWithHeaders(config: {
spreadsheetId: string;
sheetName?: string;
headers: string[];
values: (string | number | boolean | null | undefined)[];
}): Promise<void> {
const { spreadsheetId, sheetName = 'Sheet1', headers, values } = config;

this.logger.log(
`Appending row with headers to spreadsheet ${spreadsheetId}, sheet: ${sheetName}`,
);

const sheets = await this.getClient();

// Check if the sheet already has data
const existingData = await sheets.spreadsheets.values.get({
spreadsheetId,
range: `${sheetName}!A1:A1`,
});

const hasData =
existingData.data.values && existingData.data.values.length > 0;

// Convert values to strings
const stringValues = values.map((v) => {
if (v === null || v === undefined) return '';
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
return String(v);
});

if (!hasData) {
// Sheet is empty - add headers first, then the data row
this.logger.log('Sheet is empty, adding headers first');
await sheets.spreadsheets.values.append({
spreadsheetId,
range: `${sheetName}!A:Z`,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: {
values: [headers, stringValues],
},
});
} else {
// Sheet has data - just append the row
await sheets.spreadsheets.values.append({
spreadsheetId,
range: `${sheetName}!A:Z`,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: {
values: [stringValues],
},
});
}

this.logger.log('Row appended successfully');
}
}
2 changes: 2 additions & 0 deletions src/google-sheets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './google-sheets.service';
export * from './google-sheets.module';
Loading