n8n-nodes-google-classroom-trigger-student 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ import { INodeExecutionData, INodeType, INodeTypeDescription, IPollFunctions } from 'n8n-workflow';
2
+ export declare class GoogleClassroomTrigger implements INodeType {
3
+ description: INodeTypeDescription;
4
+ poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null>;
5
+ }
@@ -0,0 +1,395 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GoogleClassroomTrigger = void 0;
4
+ const n8n_workflow_1 = require("n8n-workflow");
5
+ class GoogleClassroomTrigger {
6
+ constructor() {
7
+ this.description = {
8
+ displayName: 'Google Classroom Trigger',
9
+ name: 'googleClassroomTrigger',
10
+ icon: 'file:googleClassroom.svg',
11
+ group: ['trigger'],
12
+ version: 1,
13
+ description: 'Triggers when a new unsubmitted assignment is posted in Google Classroom',
14
+ subtitle: 'New Assignment',
15
+ polling: true,
16
+ defaults: {
17
+ name: 'Google Classroom Trigger',
18
+ },
19
+ inputs: [],
20
+ outputs: [n8n_workflow_1.NodeConnectionTypes.Main],
21
+ // Reuses n8n built-in Google OAuth2 — identical to Gmail and Drive nodes
22
+ credentials: [
23
+ {
24
+ name: 'googleOAuth2Api',
25
+ required: true,
26
+ },
27
+ ],
28
+ properties: [
29
+ {
30
+ displayName: 'Poll Times',
31
+ name: 'pollTimes',
32
+ type: 'fixedCollection',
33
+ typeOptions: {
34
+ multipleValues: true,
35
+ },
36
+ default: { item: [{ mode: 'everyFifteenMinutes' }] },
37
+ description: 'How often to check for new assignments',
38
+ options: [
39
+ {
40
+ name: 'item',
41
+ displayName: 'Item',
42
+ values: [
43
+ {
44
+ displayName: 'Mode',
45
+ name: 'mode',
46
+ type: 'options',
47
+ options: [
48
+ { name: 'Every Minute', value: 'everyMinute' },
49
+ { name: 'Every 5 Minutes', value: 'everyFiveMinutes' },
50
+ { name: 'Every 15 Minutes', value: 'everyFifteenMinutes' },
51
+ { name: 'Every Hour', value: 'everyHour' },
52
+ { name: 'Custom (Cron)', value: 'custom' },
53
+ ],
54
+ default: 'everyFifteenMinutes',
55
+ },
56
+ {
57
+ displayName: 'Cron Expression',
58
+ name: 'cronExpression',
59
+ type: 'string',
60
+ displayOptions: {
61
+ show: { mode: ['custom'] },
62
+ },
63
+ default: '0 */15 * * * *',
64
+ description: 'Custom cron expression',
65
+ },
66
+ ],
67
+ },
68
+ ],
69
+ },
70
+ {
71
+ displayName: 'This node checks all your enrolled courses and triggers once per new unsubmitted assignment. All attached files are downloaded automatically.',
72
+ name: 'notice',
73
+ type: 'notice',
74
+ default: '',
75
+ },
76
+ ],
77
+ };
78
+ }
79
+ // ─── POLL FUNCTION ────────────────────────────────────────────────
80
+ // Runs on every poll interval — returns new items or null
81
+ async poll() {
82
+ // staticData persists between polls to track seen assignment IDs
83
+ const staticData = this.getWorkflowStaticData('node');
84
+ if (!staticData.lastSeenIds) {
85
+ staticData.lastSeenIds = [];
86
+ }
87
+ const returnData = [];
88
+ try {
89
+ // ── Get OAuth2 access token from n8n Google credential ───────
90
+ const credentials = await this.getCredentials('googleOAuth2Api');
91
+ const tokenData = credentials.oauthTokenData;
92
+ const accessToken = tokenData
93
+ ? tokenData.access_token
94
+ : credentials.accessToken;
95
+ const headers = {
96
+ Authorization: `Bearer ${accessToken}`,
97
+ 'Content-Type': 'application/json',
98
+ };
99
+ // ── STEP 1: Fetch all active courses student is enrolled in ──
100
+ const coursesResponse = await this.helpers.request({
101
+ method: 'GET',
102
+ url: 'https://classroom.googleapis.com/v1/courses',
103
+ headers,
104
+ qs: {
105
+ studentId: 'me',
106
+ courseStates: 'ACTIVE',
107
+ pageSize: 50,
108
+ },
109
+ json: true,
110
+ });
111
+ const courses = coursesResponse.courses || [];
112
+ // Nothing to do if student has no active courses
113
+ if (courses.length === 0) {
114
+ return null;
115
+ }
116
+ // ── STEP 2: Loop through every course ───────────────────────
117
+ for (const course of courses) {
118
+ const courseId = course.id;
119
+ const courseName = course.name;
120
+ // ── STEP 3: Get assignments for this course ──────────────
121
+ let courseWorkItems = [];
122
+ try {
123
+ const courseWorkResponse = await this.helpers.request({
124
+ method: 'GET',
125
+ url: `https://classroom.googleapis.com/v1/courses/${courseId}/courseWork`,
126
+ headers,
127
+ qs: {
128
+ orderBy: 'updateTime desc',
129
+ pageSize: 20,
130
+ },
131
+ json: true,
132
+ });
133
+ courseWorkItems =
134
+ courseWorkResponse.courseWork || [];
135
+ }
136
+ catch {
137
+ // Skip this course if student has no access
138
+ continue;
139
+ }
140
+ // ── STEP 4: Process each assignment ─────────────────────
141
+ for (const work of courseWorkItems) {
142
+ const workId = work.id;
143
+ const workTitle = work.title;
144
+ // Skip assignments we already processed in a previous poll
145
+ if (staticData.lastSeenIds.includes(workId)) {
146
+ continue;
147
+ }
148
+ // ── STEP 5: Check if student already submitted ─────────
149
+ let submissionState = 'NEW';
150
+ try {
151
+ const subResponse = await this.helpers.request({
152
+ method: 'GET',
153
+ url: `https://classroom.googleapis.com/v1/courses/${courseId}/courseWork/${workId}/studentSubmissions`,
154
+ headers,
155
+ qs: { userId: 'me' },
156
+ json: true,
157
+ });
158
+ const submissions = subResponse.studentSubmissions || [];
159
+ if (submissions.length > 0) {
160
+ submissionState = submissions[0].state;
161
+ }
162
+ }
163
+ catch {
164
+ // If check fails, treat as not submitted so we don't miss it
165
+ submissionState = 'NEW';
166
+ }
167
+ // ── STEP 6: Skip already submitted assignments ──────────
168
+ // States: NEW, CREATED, TURNED_IN, RETURNED, RECLAIMED_BY_STUDENT
169
+ if (submissionState === 'TURNED_IN' ||
170
+ submissionState === 'RETURNED') {
171
+ staticData.lastSeenIds.push(workId);
172
+ continue;
173
+ }
174
+ // ── STEP 7: Format deadline as human readable ───────────
175
+ let deadlineFormatted = 'No deadline set';
176
+ if (work.dueDate) {
177
+ const due = work.dueDate;
178
+ const dueTime = work.dueTime || {};
179
+ const year = due.year;
180
+ const month = due.month; // 1–12
181
+ const day = due.day;
182
+ const hours = dueTime.hours || 23;
183
+ const minutes = dueTime.minutes || 59;
184
+ const dateObj = new Date(year, month - 1, day, hours, minutes);
185
+ // Output: "March 25, 2025 at 11:59 PM"
186
+ deadlineFormatted = dateObj.toLocaleString('en-US', {
187
+ year: 'numeric',
188
+ month: 'long',
189
+ day: 'numeric',
190
+ hour: 'numeric',
191
+ minute: '2-digit',
192
+ hour12: true,
193
+ });
194
+ }
195
+ // ── STEP 8: Process all attachments ─────────────────────
196
+ const materials = work.materials || [];
197
+ if (materials.length === 0) {
198
+ // No attachments — emit metadata only item
199
+ returnData.push({
200
+ json: {
201
+ courseId,
202
+ courseName,
203
+ assignmentId: workId,
204
+ assignmentTitle: workTitle,
205
+ description: work.description || '',
206
+ deadline: deadlineFormatted,
207
+ totalPoints: work.maxPoints || 0,
208
+ submissionState,
209
+ hasAttachments: false,
210
+ fileName: null,
211
+ fileMimeType: null,
212
+ fileType: null,
213
+ classroomLink: work.alternateLink || '',
214
+ },
215
+ });
216
+ }
217
+ else {
218
+ // One output item per attachment file
219
+ for (const material of materials) {
220
+ // ── YouTube video — no binary, pass URL only ─────────
221
+ if (material.youtubeVideo) {
222
+ const yt = material.youtubeVideo;
223
+ returnData.push({
224
+ json: {
225
+ courseId,
226
+ courseName,
227
+ assignmentId: workId,
228
+ assignmentTitle: workTitle,
229
+ description: work.description || '',
230
+ deadline: deadlineFormatted,
231
+ totalPoints: work.maxPoints || 0,
232
+ submissionState,
233
+ hasAttachments: true,
234
+ fileName: yt.title || 'YouTube Video',
235
+ fileMimeType: 'video/youtube',
236
+ fileType: 'youtubeVideo',
237
+ youtubeUrl: yt.alternateLink,
238
+ classroomLink: work.alternateLink || '',
239
+ },
240
+ });
241
+ continue;
242
+ }
243
+ // ── External link — no binary, pass URL only ─────────
244
+ if (material.link) {
245
+ const lk = material.link;
246
+ returnData.push({
247
+ json: {
248
+ courseId,
249
+ courseName,
250
+ assignmentId: workId,
251
+ assignmentTitle: workTitle,
252
+ description: work.description || '',
253
+ deadline: deadlineFormatted,
254
+ totalPoints: work.maxPoints || 0,
255
+ submissionState,
256
+ hasAttachments: true,
257
+ fileName: lk.title || lk.url,
258
+ fileMimeType: 'text/html',
259
+ fileType: 'link',
260
+ linkUrl: lk.url,
261
+ classroomLink: work.alternateLink || '',
262
+ },
263
+ });
264
+ continue;
265
+ }
266
+ // ── Google Form — no binary, pass URL only ───────────
267
+ if (material.form) {
268
+ const fm = material.form;
269
+ returnData.push({
270
+ json: {
271
+ courseId,
272
+ courseName,
273
+ assignmentId: workId,
274
+ assignmentTitle: workTitle,
275
+ description: work.description || '',
276
+ deadline: deadlineFormatted,
277
+ totalPoints: work.maxPoints || 0,
278
+ submissionState,
279
+ hasAttachments: true,
280
+ fileName: fm.title || 'Google Form',
281
+ fileMimeType: 'application/form',
282
+ fileType: 'form',
283
+ formUrl: fm.formUrl,
284
+ classroomLink: work.alternateLink || '',
285
+ },
286
+ });
287
+ continue;
288
+ }
289
+ // ── Drive file — download as binary ──────────────────
290
+ if (material.driveFile) {
291
+ const df = material.driveFile;
292
+ const f = df.driveFile;
293
+ const driveFileId = f.id;
294
+ let fileName = f.title || 'file';
295
+ let fileMimeType = f.mimeType || 'application/octet-stream';
296
+ let downloadUrl = '';
297
+ // Google Workspace files must be exported
298
+ // Binary files (PDF, images, zip) use alt=media
299
+ if (fileMimeType.startsWith('application/vnd.google-apps')) {
300
+ const exportMap = {
301
+ 'application/vnd.google-apps.document': 'application/pdf',
302
+ 'application/vnd.google-apps.spreadsheet': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
303
+ 'application/vnd.google-apps.presentation': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
304
+ 'application/vnd.google-apps.form': 'application/pdf',
305
+ };
306
+ const exportMime = exportMap[fileMimeType] || 'application/pdf';
307
+ downloadUrl =
308
+ `https://www.googleapis.com/drive/v3/files/${driveFileId}/export?mimeType=${encodeURIComponent(exportMime)}`;
309
+ fileMimeType = exportMime;
310
+ }
311
+ else {
312
+ // Direct binary download for PDF, images, zip, etc.
313
+ downloadUrl =
314
+ `https://www.googleapis.com/drive/v3/files/${driveFileId}?alt=media`;
315
+ }
316
+ try {
317
+ // Download file as raw buffer
318
+ const fileBuffer = await this.helpers.request({
319
+ method: 'GET',
320
+ url: downloadUrl,
321
+ headers,
322
+ encoding: null,
323
+ });
324
+ // Convert buffer to n8n binary format
325
+ const binaryData = await this.helpers.prepareBinaryData(fileBuffer, fileName, fileMimeType);
326
+ returnData.push({
327
+ json: {
328
+ courseId,
329
+ courseName,
330
+ assignmentId: workId,
331
+ assignmentTitle: workTitle,
332
+ description: work.description || '',
333
+ deadline: deadlineFormatted,
334
+ totalPoints: work.maxPoints || 0,
335
+ submissionState,
336
+ hasAttachments: true,
337
+ fileName,
338
+ fileMimeType,
339
+ fileType: 'driveFile',
340
+ driveFileId,
341
+ classroomLink: work.alternateLink || '',
342
+ },
343
+ binary: {
344
+ data: binaryData,
345
+ },
346
+ });
347
+ }
348
+ catch {
349
+ // Download failed — still emit JSON so workflow
350
+ // does not break silently
351
+ returnData.push({
352
+ json: {
353
+ courseId,
354
+ courseName,
355
+ assignmentId: workId,
356
+ assignmentTitle: workTitle,
357
+ description: work.description || '',
358
+ deadline: deadlineFormatted,
359
+ totalPoints: work.maxPoints || 0,
360
+ submissionState,
361
+ hasAttachments: true,
362
+ fileName,
363
+ fileMimeType,
364
+ fileType: 'driveFile',
365
+ driveFileId,
366
+ classroomLink: work.alternateLink || '',
367
+ downloadError: 'File download failed — check Drive permissions',
368
+ },
369
+ });
370
+ }
371
+ }
372
+ }
373
+ }
374
+ // Mark this assignment as seen to prevent duplicate triggers
375
+ staticData.lastSeenIds.push(workId);
376
+ }
377
+ }
378
+ // Trim seen IDs to last 500 to prevent memory growing forever
379
+ if (staticData.lastSeenIds.length > 500) {
380
+ staticData.lastSeenIds = staticData.lastSeenIds.slice(-500);
381
+ }
382
+ // Return null if nothing new found this poll cycle
383
+ return returnData.length === 0 ? null : [returnData];
384
+ }
385
+ catch (error) {
386
+ // Throw plain Error — works cleanly with all n8n TypeScript versions
387
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
388
+ throw new Error(`Google Classroom Trigger failed: ${errorMessage}. ` +
389
+ `Please check your Google OAuth2 credentials and ensure ` +
390
+ `Classroom API and Drive API are enabled in Google Cloud Console.`);
391
+ }
392
+ }
393
+ }
394
+ exports.GoogleClassroomTrigger = GoogleClassroomTrigger;
395
+ //# sourceMappingURL=GoogleClassroomTrigger.node.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GoogleClassroomTrigger.node.js","sourceRoot":"","sources":["../../../nodes/GoogleClassroomTrigger/GoogleClassroomTrigger.node.ts"],"names":[],"mappings":"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"}
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,451 @@
1
+ import {
2
+ IDataObject,
3
+ INodeExecutionData,
4
+ INodeType,
5
+ INodeTypeDescription,
6
+ IPollFunctions,
7
+ NodeConnectionTypes,
8
+ } from 'n8n-workflow';
9
+
10
+ export class GoogleClassroomTrigger implements INodeType {
11
+ description: INodeTypeDescription = {
12
+ displayName: 'Google Classroom Trigger',
13
+ name: 'googleClassroomTrigger',
14
+ icon: 'file:googleClassroom.svg',
15
+ group: ['trigger'],
16
+ version: 1,
17
+ description: 'Triggers when a new unsubmitted assignment is posted in Google Classroom',
18
+ subtitle: 'New Assignment',
19
+ polling: true,
20
+ defaults: {
21
+ name: 'Google Classroom Trigger',
22
+ },
23
+ inputs: [],
24
+ outputs: [NodeConnectionTypes.Main],
25
+
26
+ // Reuses n8n built-in Google OAuth2 — identical to Gmail and Drive nodes
27
+ credentials: [
28
+ {
29
+ name: 'googleOAuth2Api',
30
+ required: true,
31
+ },
32
+ ],
33
+
34
+ properties: [
35
+ {
36
+ displayName: 'Poll Times',
37
+ name: 'pollTimes',
38
+ type: 'fixedCollection',
39
+ typeOptions: {
40
+ multipleValues: true,
41
+ },
42
+ default: { item: [{ mode: 'everyFifteenMinutes' }] },
43
+ description: 'How often to check for new assignments',
44
+ options: [
45
+ {
46
+ name: 'item',
47
+ displayName: 'Item',
48
+ values: [
49
+ {
50
+ displayName: 'Mode',
51
+ name: 'mode',
52
+ type: 'options',
53
+ options: [
54
+ { name: 'Every Minute', value: 'everyMinute' },
55
+ { name: 'Every 5 Minutes', value: 'everyFiveMinutes' },
56
+ { name: 'Every 15 Minutes', value: 'everyFifteenMinutes' },
57
+ { name: 'Every Hour', value: 'everyHour' },
58
+ { name: 'Custom (Cron)', value: 'custom' },
59
+ ],
60
+ default: 'everyFifteenMinutes',
61
+ },
62
+ {
63
+ displayName: 'Cron Expression',
64
+ name: 'cronExpression',
65
+ type: 'string',
66
+ displayOptions: {
67
+ show: { mode: ['custom'] },
68
+ },
69
+ default: '0 */15 * * * *',
70
+ description: 'Custom cron expression',
71
+ },
72
+ ],
73
+ },
74
+ ],
75
+ },
76
+ {
77
+ displayName: 'This node checks all your enrolled courses and triggers once per new unsubmitted assignment. All attached files are downloaded automatically.',
78
+ name: 'notice',
79
+ type: 'notice',
80
+ default: '',
81
+ },
82
+ ],
83
+ };
84
+
85
+ // ─── POLL FUNCTION ────────────────────────────────────────────────
86
+ // Runs on every poll interval — returns new items or null
87
+ async poll(this: IPollFunctions): Promise<INodeExecutionData[][] | null> {
88
+
89
+ // staticData persists between polls to track seen assignment IDs
90
+ const staticData = this.getWorkflowStaticData('node') as {
91
+ lastSeenIds?: string[];
92
+ };
93
+
94
+ if (!staticData.lastSeenIds) {
95
+ staticData.lastSeenIds = [];
96
+ }
97
+
98
+ const returnData: INodeExecutionData[] = [];
99
+
100
+ try {
101
+ // ── Get OAuth2 access token from n8n Google credential ───────
102
+ const credentials = await this.getCredentials('googleOAuth2Api');
103
+ const tokenData = credentials.oauthTokenData as IDataObject | undefined;
104
+ const accessToken = tokenData
105
+ ? (tokenData.access_token as string)
106
+ : (credentials.accessToken as string);
107
+
108
+ const headers = {
109
+ Authorization: `Bearer ${accessToken}`,
110
+ 'Content-Type': 'application/json',
111
+ };
112
+
113
+ // ── STEP 1: Fetch all active courses student is enrolled in ──
114
+ const coursesResponse = await this.helpers.request({
115
+ method: 'GET',
116
+ url: 'https://classroom.googleapis.com/v1/courses',
117
+ headers,
118
+ qs: {
119
+ studentId: 'me',
120
+ courseStates: 'ACTIVE',
121
+ pageSize: 50,
122
+ },
123
+ json: true,
124
+ }) as IDataObject;
125
+
126
+ const courses: IDataObject[] =
127
+ (coursesResponse.courses as IDataObject[]) || [];
128
+
129
+ // Nothing to do if student has no active courses
130
+ if (courses.length === 0) {
131
+ return null;
132
+ }
133
+
134
+ // ── STEP 2: Loop through every course ───────────────────────
135
+ for (const course of courses) {
136
+ const courseId = course.id as string;
137
+ const courseName = course.name as string;
138
+
139
+ // ── STEP 3: Get assignments for this course ──────────────
140
+ let courseWorkItems: IDataObject[] = [];
141
+ try {
142
+ const courseWorkResponse = await this.helpers.request({
143
+ method: 'GET',
144
+ url: `https://classroom.googleapis.com/v1/courses/${courseId}/courseWork`,
145
+ headers,
146
+ qs: {
147
+ orderBy: 'updateTime desc',
148
+ pageSize: 20,
149
+ },
150
+ json: true,
151
+ }) as IDataObject;
152
+
153
+ courseWorkItems =
154
+ (courseWorkResponse.courseWork as IDataObject[]) || [];
155
+ } catch {
156
+ // Skip this course if student has no access
157
+ continue;
158
+ }
159
+
160
+ // ── STEP 4: Process each assignment ─────────────────────
161
+ for (const work of courseWorkItems) {
162
+ const workId = work.id as string;
163
+ const workTitle = work.title as string;
164
+
165
+ // Skip assignments we already processed in a previous poll
166
+ if (staticData.lastSeenIds!.includes(workId)) {
167
+ continue;
168
+ }
169
+
170
+ // ── STEP 5: Check if student already submitted ─────────
171
+ let submissionState = 'NEW';
172
+ try {
173
+ const subResponse = await this.helpers.request({
174
+ method: 'GET',
175
+ url: `https://classroom.googleapis.com/v1/courses/${courseId}/courseWork/${workId}/studentSubmissions`,
176
+ headers,
177
+ qs: { userId: 'me' },
178
+ json: true,
179
+ }) as IDataObject;
180
+
181
+ const submissions =
182
+ (subResponse.studentSubmissions as IDataObject[]) || [];
183
+
184
+ if (submissions.length > 0) {
185
+ submissionState = submissions[0].state as string;
186
+ }
187
+ } catch {
188
+ // If check fails, treat as not submitted so we don't miss it
189
+ submissionState = 'NEW';
190
+ }
191
+
192
+ // ── STEP 6: Skip already submitted assignments ──────────
193
+ // States: NEW, CREATED, TURNED_IN, RETURNED, RECLAIMED_BY_STUDENT
194
+ if (
195
+ submissionState === 'TURNED_IN' ||
196
+ submissionState === 'RETURNED'
197
+ ) {
198
+ staticData.lastSeenIds!.push(workId);
199
+ continue;
200
+ }
201
+
202
+ // ── STEP 7: Format deadline as human readable ───────────
203
+ let deadlineFormatted = 'No deadline set';
204
+
205
+ if (work.dueDate) {
206
+ const due = work.dueDate as IDataObject;
207
+ const dueTime = (work.dueTime as IDataObject) || {};
208
+
209
+ const year = due.year as number;
210
+ const month = due.month as number; // 1–12
211
+ const day = due.day as number;
212
+ const hours = (dueTime.hours as number) || 23;
213
+ const minutes = (dueTime.minutes as number) || 59;
214
+
215
+ const dateObj = new Date(year, month - 1, day, hours, minutes);
216
+
217
+ // Output: "March 25, 2025 at 11:59 PM"
218
+ deadlineFormatted = dateObj.toLocaleString('en-US', {
219
+ year: 'numeric',
220
+ month: 'long',
221
+ day: 'numeric',
222
+ hour: 'numeric',
223
+ minute: '2-digit',
224
+ hour12: true,
225
+ });
226
+ }
227
+
228
+ // ── STEP 8: Process all attachments ─────────────────────
229
+ const materials = (work.materials as IDataObject[]) || [];
230
+
231
+ if (materials.length === 0) {
232
+ // No attachments — emit metadata only item
233
+ returnData.push({
234
+ json: {
235
+ courseId,
236
+ courseName,
237
+ assignmentId: workId,
238
+ assignmentTitle: workTitle,
239
+ description: (work.description as string) || '',
240
+ deadline: deadlineFormatted,
241
+ totalPoints: (work.maxPoints as number) || 0,
242
+ submissionState,
243
+ hasAttachments: false,
244
+ fileName: null,
245
+ fileMimeType: null,
246
+ fileType: null,
247
+ classroomLink: (work.alternateLink as string) || '',
248
+ },
249
+ });
250
+
251
+ } else {
252
+
253
+ // One output item per attachment file
254
+ for (const material of materials) {
255
+
256
+ // ── YouTube video — no binary, pass URL only ─────────
257
+ if (material.youtubeVideo) {
258
+ const yt = material.youtubeVideo as IDataObject;
259
+ returnData.push({
260
+ json: {
261
+ courseId,
262
+ courseName,
263
+ assignmentId: workId,
264
+ assignmentTitle: workTitle,
265
+ description: (work.description as string) || '',
266
+ deadline: deadlineFormatted,
267
+ totalPoints: (work.maxPoints as number) || 0,
268
+ submissionState,
269
+ hasAttachments: true,
270
+ fileName: (yt.title as string) || 'YouTube Video',
271
+ fileMimeType: 'video/youtube',
272
+ fileType: 'youtubeVideo',
273
+ youtubeUrl: yt.alternateLink as string,
274
+ classroomLink: (work.alternateLink as string) || '',
275
+ },
276
+ });
277
+ continue;
278
+ }
279
+
280
+ // ── External link — no binary, pass URL only ─────────
281
+ if (material.link) {
282
+ const lk = material.link as IDataObject;
283
+ returnData.push({
284
+ json: {
285
+ courseId,
286
+ courseName,
287
+ assignmentId: workId,
288
+ assignmentTitle: workTitle,
289
+ description: (work.description as string) || '',
290
+ deadline: deadlineFormatted,
291
+ totalPoints: (work.maxPoints as number) || 0,
292
+ submissionState,
293
+ hasAttachments: true,
294
+ fileName: (lk.title as string) || (lk.url as string),
295
+ fileMimeType: 'text/html',
296
+ fileType: 'link',
297
+ linkUrl: lk.url as string,
298
+ classroomLink: (work.alternateLink as string) || '',
299
+ },
300
+ });
301
+ continue;
302
+ }
303
+
304
+ // ── Google Form — no binary, pass URL only ───────────
305
+ if (material.form) {
306
+ const fm = material.form as IDataObject;
307
+ returnData.push({
308
+ json: {
309
+ courseId,
310
+ courseName,
311
+ assignmentId: workId,
312
+ assignmentTitle: workTitle,
313
+ description: (work.description as string) || '',
314
+ deadline: deadlineFormatted,
315
+ totalPoints: (work.maxPoints as number) || 0,
316
+ submissionState,
317
+ hasAttachments: true,
318
+ fileName: (fm.title as string) || 'Google Form',
319
+ fileMimeType: 'application/form',
320
+ fileType: 'form',
321
+ formUrl: fm.formUrl as string,
322
+ classroomLink: (work.alternateLink as string) || '',
323
+ },
324
+ });
325
+ continue;
326
+ }
327
+
328
+ // ── Drive file — download as binary ──────────────────
329
+ if (material.driveFile) {
330
+ const df = material.driveFile as IDataObject;
331
+ const f = df.driveFile as IDataObject;
332
+ const driveFileId = f.id as string;
333
+ let fileName = (f.title as string) || 'file';
334
+ let fileMimeType = (f.mimeType as string) || 'application/octet-stream';
335
+ let downloadUrl = '';
336
+
337
+ // Google Workspace files must be exported
338
+ // Binary files (PDF, images, zip) use alt=media
339
+ if (fileMimeType.startsWith('application/vnd.google-apps')) {
340
+ const exportMap: Record<string, string> = {
341
+ 'application/vnd.google-apps.document':
342
+ 'application/pdf',
343
+ 'application/vnd.google-apps.spreadsheet':
344
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
345
+ 'application/vnd.google-apps.presentation':
346
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
347
+ 'application/vnd.google-apps.form':
348
+ 'application/pdf',
349
+ };
350
+ const exportMime =
351
+ exportMap[fileMimeType] || 'application/pdf';
352
+ downloadUrl =
353
+ `https://www.googleapis.com/drive/v3/files/${driveFileId}/export?mimeType=${encodeURIComponent(exportMime)}`;
354
+ fileMimeType = exportMime;
355
+ } else {
356
+ // Direct binary download for PDF, images, zip, etc.
357
+ downloadUrl =
358
+ `https://www.googleapis.com/drive/v3/files/${driveFileId}?alt=media`;
359
+ }
360
+
361
+ try {
362
+ // Download file as raw buffer
363
+ const fileBuffer = await this.helpers.request({
364
+ method: 'GET',
365
+ url: downloadUrl,
366
+ headers,
367
+ encoding: null,
368
+ }) as Buffer;
369
+
370
+ // Convert buffer to n8n binary format
371
+ const binaryData = await this.helpers.prepareBinaryData(
372
+ fileBuffer,
373
+ fileName,
374
+ fileMimeType,
375
+ );
376
+
377
+ returnData.push({
378
+ json: {
379
+ courseId,
380
+ courseName,
381
+ assignmentId: workId,
382
+ assignmentTitle: workTitle,
383
+ description: (work.description as string) || '',
384
+ deadline: deadlineFormatted,
385
+ totalPoints: (work.maxPoints as number) || 0,
386
+ submissionState,
387
+ hasAttachments: true,
388
+ fileName,
389
+ fileMimeType,
390
+ fileType: 'driveFile',
391
+ driveFileId,
392
+ classroomLink: (work.alternateLink as string) || '',
393
+ },
394
+ binary: {
395
+ data: binaryData,
396
+ },
397
+ });
398
+
399
+ } catch {
400
+ // Download failed — still emit JSON so workflow
401
+ // does not break silently
402
+ returnData.push({
403
+ json: {
404
+ courseId,
405
+ courseName,
406
+ assignmentId: workId,
407
+ assignmentTitle: workTitle,
408
+ description: (work.description as string) || '',
409
+ deadline: deadlineFormatted,
410
+ totalPoints: (work.maxPoints as number) || 0,
411
+ submissionState,
412
+ hasAttachments: true,
413
+ fileName,
414
+ fileMimeType,
415
+ fileType: 'driveFile',
416
+ driveFileId,
417
+ classroomLink: (work.alternateLink as string) || '',
418
+ downloadError:
419
+ 'File download failed — check Drive permissions',
420
+ },
421
+ });
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ // Mark this assignment as seen to prevent duplicate triggers
428
+ staticData.lastSeenIds!.push(workId);
429
+ }
430
+ }
431
+
432
+ // Trim seen IDs to last 500 to prevent memory growing forever
433
+ if (staticData.lastSeenIds!.length > 500) {
434
+ staticData.lastSeenIds = staticData.lastSeenIds!.slice(-500);
435
+ }
436
+
437
+ // Return null if nothing new found this poll cycle
438
+ return returnData.length === 0 ? null : [returnData];
439
+
440
+ } catch (error) {
441
+ // Throw plain Error — works cleanly with all n8n TypeScript versions
442
+ const errorMessage =
443
+ error instanceof Error ? error.message : 'Unknown error occurred';
444
+ throw new Error(
445
+ `Google Classroom Trigger failed: ${errorMessage}. ` +
446
+ `Please check your Google OAuth2 credentials and ensure ` +
447
+ `Classroom API and Drive API are enabled in Google Cloud Console.`
448
+ );
449
+ }
450
+ }
451
+ }
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
2
+ <rect width="48" height="48" rx="8" fill="#0F9D58"/>
3
+ <text x="50%" y="55%" font-size="22" fill="white" text-anchor="middle" dominant-baseline="middle" font-family="Arial">GC</text>
4
+ </svg>
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "n8n-nodes-google-classroom-trigger-student",
3
+ "version": "1.0.0",
4
+ "description": "Google Classroom Trigger for n8n - triggers on new unsubmitted assignments and downloads all attachments",
5
+ "keywords": [
6
+ "n8n-community-node-package",
7
+ "n8n",
8
+ "google-classroom",
9
+ "classroom",
10
+ "education",
11
+ "trigger",
12
+ "student"
13
+ ],
14
+ "license": "MIT",
15
+ "main": "index.js",
16
+ "scripts": {
17
+ "build": "tsc"
18
+ },
19
+ "n8n": {
20
+ "n8nNodesApiVersion": 1,
21
+ "nodes": [
22
+ "dist/nodes/GoogleClassroomTrigger/GoogleClassroomTrigger.node.js"
23
+ ]
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^5.0.0",
27
+ "@types/node": "^18.0.0"
28
+ },
29
+ "peerDependencies": {
30
+ "n8n-workflow": "*"
31
+ }
32
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2019",
4
+ "module": "commonjs",
5
+ "lib": ["ES2019"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./",
8
+ "strict": false,
9
+ "esModuleInterop": true,
10
+ "resolveJsonModule": true,
11
+ "declaration": true,
12
+ "sourceMap": true,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["nodes/**/*"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }