-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add saving form submissions to google sheet #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sajclarke
wants to merge
4
commits into
develop
Choose a base branch
from
feat/google-sheet-processing
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c47b584
feat: add saving form submissions to google sheet
sajclarke ee70593
Merge branch 'develop' into feat/google-sheet-processing
sajclarke 0c3799a
chore: update to support aws secrets manager
sajclarke 9b2ba35
Merge branch 'develop' into feat/google-sheet-processing
sajclarke File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 {} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from './google-sheets.service'; | ||
| export * from './google-sheets.module'; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?