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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- Preserve the domain-owner invariant: `domain.email` identifies the school owner and public API keys resolve that owner as the API actor. Do not use raw `UserModel.update*`, `UserModel.delete*`, `DomainModel.update*`, migrations, or scripts in a way that changes/deletes the owner user, changes the owner user's permissions, or drifts `domain.email` away from the owner user without adding explicit guards and tests.
- Refrain from adding new GraphQL query/mutation unless required. If an existing query/mutation can be modified to implement the feature without making the query's/mutation's boundaries blurry, extend those.
- Always keep openapi.mjs files in sync with the actual implementation of the API endpoints.
- While adding a new collection, always confirm how the deletion/cleanup will work for it.

### Workspace map (core modules):

Expand Down
2 changes: 1 addition & 1 deletion apps/docs-new/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function makePayload(overrides: Partial<any> = {}): any {
email: "student@example.com",
unsubscribeToken: "unsubscribe-token",
subscribedToUpdates: true,
permissions: ["course:manage_any"],
},
activityType: Constants.ActivityType.ENROLLED,
entityId: "entity-id",
Expand Down Expand Up @@ -70,6 +71,12 @@ describe("EmailChannel", () => {
it("renders a notification email with actor avatar, CTA, footer unsubscribe, branding, and unsubscribe headers", async () => {
await new EmailChannel().send(makePayload());

expect(mockedGetNotificationMessageAndHref).toHaveBeenCalledWith(
expect.objectContaining({
recipientPermissions: ["course:manage_any"],
}),
);

expect(mockedAddMailJob).toHaveBeenCalledTimes(1);
const mail = mockedAddMailJob.mock.calls[0][0];

Expand Down
1 change: 1 addition & 0 deletions apps/queue/src/notifications/services/channels/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class EmailChannel implements NotificationChannel {
entityId: payload.entityId,
actorName,
recipientUserId: payload.recipient.userId,
recipientPermissions: payload.recipient.permissions || [],
entityTargetId: payload.entityTargetId,
metadata: payload.metadata,
hrefPrefix: getSiteUrl(payload.domain),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Seeds granular product discussion notification preferences for existing
* users.
*
* Usage: DB_CONNECTION_STRING=<mongodb-connection-string> node 21-06-26_12-30-seed-product-discussion-notification-preferences.js
*/
import mongoose from "mongoose";

const DB_CONNECTION_STRING = process.env.DB_CONNECTION_STRING;
if (!DB_CONNECTION_STRING) {
throw new Error("DB_CONNECTION_STRING is not set");
}

const PRODUCT_DISCUSSION_ACTIVITY_TYPES = [
"course_discussion_comment_created",
"course_discussion_reacted",
];
const DEFAULT_CHANNELS = ["app", "email"];
const BATCH_SIZE = 500;

const UserSchema = new mongoose.Schema({
domain: { type: mongoose.Schema.Types.ObjectId, required: true },
userId: { type: String, required: true },
});

const NotificationPreferenceSchema = new mongoose.Schema(
{
domain: { type: mongoose.Schema.Types.ObjectId, required: true },
userId: { type: String, required: true },
activityType: { type: String, required: true },
channels: { type: [String], default: [] },
},
{
timestamps: true,
},
);

NotificationPreferenceSchema.index(
{
domain: 1,
userId: 1,
activityType: 1,
},
{
unique: true,
},
);

const User = mongoose.model("User", UserSchema);
const NotificationPreference = mongoose.model(
"NotificationPreference",
NotificationPreferenceSchema,
);

function getPreferenceOps({ domain, userId }) {
return PRODUCT_DISCUSSION_ACTIVITY_TYPES.map((activityType) => ({
updateOne: {
filter: {
domain,
userId,
activityType,
},
update: {
$setOnInsert: {
domain,
userId,
activityType,
channels: DEFAULT_CHANNELS,
},
},
upsert: true,
},
}));
}

async function flushBatch(batch) {
if (!batch.length) {
return;
}

await NotificationPreference.bulkWrite(batch, { ordered: false });
batch.length = 0;
}

async function seedNotificationPreferences() {
const cursor = User.find(
{},
{
_id: 0,
domain: 1,
userId: 1,
},
)
.lean()
.cursor();

let processedUsers = 0;
let totalOps = 0;
const batch = [];

for await (const user of cursor) {
const ops = getPreferenceOps({
domain: user.domain,
userId: user.userId,
});

batch.push(...ops);
processedUsers += 1;
totalOps += ops.length;

if (batch.length >= BATCH_SIZE) {
await flushBatch(batch);
}
}

await flushBatch(batch);

console.log(
`Seeded product discussion preferences for ${processedUsers} users (${totalOps} idempotent upserts).`,
);
}

(async () => {
try {
await mongoose.connect(DB_CONNECTION_STRING);

await seedNotificationPreferences();
} finally {
await mongoose.connection.close();
}
})();
Loading
Loading