@headless-lms/adapter-db 0.1.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.
- package/LICENSE +21 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.js +11 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +22 -0
- package/dist/migrate.d.ts +7 -0
- package/dist/migrate.js +27 -0
- package/dist/migrate.js.map +1 -0
- package/dist/repositories/assets.d.ts +17 -0
- package/dist/repositories/assets.js +90 -0
- package/dist/repositories/assets.js.map +1 -0
- package/dist/repositories/automations.d.ts +30 -0
- package/dist/repositories/automations.js +165 -0
- package/dist/repositories/automations.js.map +1 -0
- package/dist/repositories/content.d.ts +52 -0
- package/dist/repositories/content.js +350 -0
- package/dist/repositories/content.js.map +1 -0
- package/dist/repositories/credentials.d.ts +25 -0
- package/dist/repositories/credentials.js +86 -0
- package/dist/repositories/credentials.js.map +1 -0
- package/dist/repositories/dashboard.d.ts +14 -0
- package/dist/repositories/dashboard.js +55 -0
- package/dist/repositories/dashboard.js.map +1 -0
- package/dist/repositories/discussion.d.ts +31 -0
- package/dist/repositories/discussion.js +201 -0
- package/dist/repositories/discussion.js.map +1 -0
- package/dist/repositories/entitlements.d.ts +23 -0
- package/dist/repositories/entitlements.js +129 -0
- package/dist/repositories/entitlements.js.map +1 -0
- package/dist/repositories/identity.d.ts +17 -0
- package/dist/repositories/identity.js +44 -0
- package/dist/repositories/identity.js.map +1 -0
- package/dist/repositories/integrations.d.ts +18 -0
- package/dist/repositories/integrations.js +65 -0
- package/dist/repositories/integrations.js.map +1 -0
- package/dist/repositories/learn.d.ts +19 -0
- package/dist/repositories/learn.js +54 -0
- package/dist/repositories/learn.js.map +1 -0
- package/dist/repositories/members.d.ts +16 -0
- package/dist/repositories/members.js +116 -0
- package/dist/repositories/members.js.map +1 -0
- package/dist/repositories/org-user-profile.d.ts +92 -0
- package/dist/repositories/org-user-profile.js +16 -0
- package/dist/repositories/org-user-profile.js.map +1 -0
- package/dist/repositories/organizations.d.ts +33 -0
- package/dist/repositories/organizations.js +198 -0
- package/dist/repositories/organizations.js.map +1 -0
- package/dist/repositories/outbox.d.ts +24 -0
- package/dist/repositories/outbox.js +56 -0
- package/dist/repositories/outbox.js.map +1 -0
- package/dist/repositories/pg-errors.d.ts +11 -0
- package/dist/repositories/pg-errors.js +15 -0
- package/dist/repositories/pg-errors.js.map +1 -0
- package/dist/repositories/progress.d.ts +18 -0
- package/dist/repositories/progress.js +59 -0
- package/dist/repositories/progress.js.map +1 -0
- package/dist/repositories/settings.d.ts +22 -0
- package/dist/repositories/settings.js +73 -0
- package/dist/repositories/settings.js.map +1 -0
- package/dist/repositories/students.d.ts +15 -0
- package/dist/repositories/students.js +83 -0
- package/dist/repositories/students.js.map +1 -0
- package/dist/schema/assets.d.ts +181 -0
- package/dist/schema/assets.js +24 -0
- package/dist/schema/assets.js.map +1 -0
- package/dist/schema/automations.d.ts +364 -0
- package/dist/schema/automations.js +42 -0
- package/dist/schema/automations.js.map +1 -0
- package/dist/schema/better-auth.d.ts +2311 -0
- package/dist/schema/better-auth.js +156 -0
- package/dist/schema/better-auth.js.map +1 -0
- package/dist/schema/content.d.ts +966 -0
- package/dist/schema/content.js +163 -0
- package/dist/schema/content.js.map +1 -0
- package/dist/schema/credentials.d.ts +113 -0
- package/dist/schema/credentials.js +15 -0
- package/dist/schema/credentials.js.map +1 -0
- package/dist/schema/discussion.d.ts +399 -0
- package/dist/schema/discussion.js +89 -0
- package/dist/schema/discussion.js.map +1 -0
- package/dist/schema/drift.d.ts +9 -0
- package/dist/schema/drift.js +1 -0
- package/dist/schema/entitlements.d.ts +147 -0
- package/dist/schema/entitlements.js +30 -0
- package/dist/schema/entitlements.js.map +1 -0
- package/dist/schema/identity.d.ts +130 -0
- package/dist/schema/identity.js +16 -0
- package/dist/schema/identity.js.map +1 -0
- package/dist/schema/index.d.ts +18 -0
- package/dist/schema/index.js +43 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/integrations.d.ts +149 -0
- package/dist/schema/integrations.js +25 -0
- package/dist/schema/integrations.js.map +1 -0
- package/dist/schema/organizations.d.ts +416 -0
- package/dist/schema/organizations.js +58 -0
- package/dist/schema/organizations.js.map +1 -0
- package/dist/schema/outbox.d.ts +166 -0
- package/dist/schema/outbox.js +19 -0
- package/dist/schema/outbox.js.map +1 -0
- package/dist/schema/progress.d.ts +166 -0
- package/dist/schema/progress.js +30 -0
- package/dist/schema/progress.js.map +1 -0
- package/dist/schema/settings.d.ts +115 -0
- package/dist/schema/settings.js +19 -0
- package/dist/schema/settings.js.map +1 -0
- package/dist/unit-of-work.d.ts +13 -0
- package/dist/unit-of-work.js +16 -0
- package/dist/unit-of-work.js.map +1 -0
- package/drizzle/0000_baseline.sql +526 -0
- package/drizzle/meta/0000_snapshot.json +3925 -0
- package/drizzle/meta/_journal.json +13 -0
- package/package.json +56 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { Comment, CommentReport, ListCommentsQuery, Logger, Page, ReactionEmoji } from "@headless-lms/core/types";
|
|
3
|
+
import { AuthorRecord, CommentReactions, CommentWithContext, DiscussionRepository } from "@headless-lms/core/discussion";
|
|
4
|
+
//#region src/repositories/discussion.d.ts
|
|
5
|
+
declare class DrizzleDiscussionRepository implements DiscussionRepository {
|
|
6
|
+
private readonly db;
|
|
7
|
+
private readonly logger;
|
|
8
|
+
constructor(db: NodePgDatabase, logger?: Logger);
|
|
9
|
+
insertComment(orgId: string, comment: Comment): Promise<Comment>;
|
|
10
|
+
findComment(orgId: string, id: string): Promise<Comment | null>;
|
|
11
|
+
updateComment(orgId: string, id: string, patch: Partial<Pick<Comment, 'body' | 'status' | 'removedBy' | 'updatedAt'>>): Promise<Comment | null>;
|
|
12
|
+
listByActivity(orgId: string, activityId: string): Promise<Comment[]>;
|
|
13
|
+
reactionsOf(orgId: string, commentIds: string[], viewerId: string): Promise<Record<string, CommentReactions>>;
|
|
14
|
+
setReaction(orgId: string, commentId: string, orgUserId: string, emoji: ReactionEmoji | null): Promise<void>;
|
|
15
|
+
authorsOf(orgId: string, orgUserIds: string[]): Promise<Record<string, AuthorRecord>>;
|
|
16
|
+
insertReport(orgId: string, report: CommentReport): Promise<CommentReport | null>;
|
|
17
|
+
listOpenReports(orgId: string, commentIds: string[]): Promise<CommentReport[]>;
|
|
18
|
+
resolveReportsFor(orgId: string, commentId: string, resolvedAt: Date): Promise<void>;
|
|
19
|
+
/** The list's one join: comments to their activity, which carries both the
|
|
20
|
+
* course (denormalised, so the module never enters the query) and the title.
|
|
21
|
+
* The course filter and the row's activity title come out of that same pass,
|
|
22
|
+
* so neither costs an extra query. */
|
|
23
|
+
private listRows;
|
|
24
|
+
/** The same join and filters, counted. Separate from `listRows` because the
|
|
25
|
+
* total must ignore limit/offset. */
|
|
26
|
+
private listTotal;
|
|
27
|
+
listComments(orgId: string, query: ListCommentsQuery): Promise<Page<CommentWithContext>>;
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { DrizzleDiscussionRepository };
|
|
31
|
+
//# sourceMappingURL=discussion.d.ts.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { users } from "../schema/identity.js";
|
|
2
|
+
import { orgUsers } from "../schema/organizations.js";
|
|
3
|
+
import { activities } from "../schema/content.js";
|
|
4
|
+
import { commentReactions, commentReports, comments } from "../schema/discussion.js";
|
|
5
|
+
import "../schema/index.js";
|
|
6
|
+
import { user } from "../schema/better-auth.js";
|
|
7
|
+
import { orgUserProfileColumns } from "./org-user-profile.js";
|
|
8
|
+
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
|
|
9
|
+
import { noopLogger } from "@headless-lms/core/shared/logger";
|
|
10
|
+
//#region src/repositories/discussion.ts
|
|
11
|
+
/** The title lives in the activity's opaque settings blob; the moderation card
|
|
12
|
+
* needs it to say "Lesson 3" rather than an id. */
|
|
13
|
+
const activityTitleExpr = sql`coalesce(${activities.settings} ->> 'title', '')`;
|
|
14
|
+
/** Sortable columns of the staff comment list, by the client-facing field name.
|
|
15
|
+
* `activityTitle` sorts on the same expression the row is served from. */
|
|
16
|
+
const sortColumns = {
|
|
17
|
+
createdAt: comments.createdAt,
|
|
18
|
+
updatedAt: comments.updatedAt,
|
|
19
|
+
status: comments.status,
|
|
20
|
+
activityTitle: activityTitleExpr
|
|
21
|
+
};
|
|
22
|
+
function toComment(row) {
|
|
23
|
+
return {
|
|
24
|
+
id: row.id,
|
|
25
|
+
orgId: row.orgId,
|
|
26
|
+
activityId: row.activityId,
|
|
27
|
+
parentId: row.parentId ?? null,
|
|
28
|
+
orgUserId: row.orgUserId,
|
|
29
|
+
body: row.body,
|
|
30
|
+
status: row.status,
|
|
31
|
+
removedBy: row.removedBy ?? null,
|
|
32
|
+
createdAt: row.createdAt,
|
|
33
|
+
updatedAt: row.updatedAt
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
var DrizzleDiscussionRepository = class {
|
|
37
|
+
db;
|
|
38
|
+
logger;
|
|
39
|
+
constructor(db, logger = noopLogger) {
|
|
40
|
+
this.db = db;
|
|
41
|
+
this.logger = logger;
|
|
42
|
+
}
|
|
43
|
+
async insertComment(orgId, comment) {
|
|
44
|
+
const [row] = await this.db.insert(comments).values({
|
|
45
|
+
id: comment.id,
|
|
46
|
+
orgId,
|
|
47
|
+
activityId: comment.activityId,
|
|
48
|
+
parentId: comment.parentId,
|
|
49
|
+
orgUserId: comment.orgUserId,
|
|
50
|
+
body: comment.body,
|
|
51
|
+
status: comment.status,
|
|
52
|
+
removedBy: comment.removedBy,
|
|
53
|
+
createdAt: new Date(comment.createdAt),
|
|
54
|
+
updatedAt: new Date(comment.updatedAt)
|
|
55
|
+
}).returning();
|
|
56
|
+
return toComment(row);
|
|
57
|
+
}
|
|
58
|
+
async findComment(orgId, id) {
|
|
59
|
+
const [row] = await this.db.select().from(comments).where(and(eq(comments.orgId, orgId), eq(comments.id, id))).limit(1);
|
|
60
|
+
return row ? toComment(row) : null;
|
|
61
|
+
}
|
|
62
|
+
async updateComment(orgId, id, patch) {
|
|
63
|
+
const [row] = await this.db.update(comments).set({
|
|
64
|
+
..."body" in patch ? { body: patch.body } : {},
|
|
65
|
+
..."status" in patch ? { status: patch.status } : {},
|
|
66
|
+
..."removedBy" in patch ? { removedBy: patch.removedBy ?? null } : {}
|
|
67
|
+
}).where(and(eq(comments.orgId, orgId), eq(comments.id, id))).returning();
|
|
68
|
+
return row ? toComment(row) : null;
|
|
69
|
+
}
|
|
70
|
+
async listByActivity(orgId, activityId) {
|
|
71
|
+
return (await this.db.select().from(comments).where(and(eq(comments.orgId, orgId), eq(comments.activityId, activityId))).orderBy(comments.createdAt)).map(toComment);
|
|
72
|
+
}
|
|
73
|
+
async reactionsOf(orgId, commentIds, viewerId) {
|
|
74
|
+
if (commentIds.length === 0) return {};
|
|
75
|
+
const rows = await this.db.select({
|
|
76
|
+
commentId: commentReactions.commentId,
|
|
77
|
+
emoji: commentReactions.emoji,
|
|
78
|
+
count: sql`cast(count(*) as int)`,
|
|
79
|
+
mine: sql`bool_or(${commentReactions.orgUserId} = ${viewerId})`
|
|
80
|
+
}).from(commentReactions).where(and(eq(commentReactions.orgId, orgId), inArray(commentReactions.commentId, commentIds))).groupBy(commentReactions.commentId, commentReactions.emoji);
|
|
81
|
+
const byComment = {};
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
const entry = byComment[row.commentId] ??= { reactions: {} };
|
|
84
|
+
entry.reactions[row.emoji] = row.count;
|
|
85
|
+
if (row.mine) entry.viewerReaction = row.emoji;
|
|
86
|
+
}
|
|
87
|
+
return byComment;
|
|
88
|
+
}
|
|
89
|
+
async setReaction(orgId, commentId, orgUserId, emoji) {
|
|
90
|
+
if (emoji === null) {
|
|
91
|
+
await this.db.delete(commentReactions).where(and(eq(commentReactions.orgId, orgId), eq(commentReactions.commentId, commentId), eq(commentReactions.orgUserId, orgUserId)));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await this.db.insert(commentReactions).values({
|
|
95
|
+
orgId,
|
|
96
|
+
commentId,
|
|
97
|
+
orgUserId,
|
|
98
|
+
emoji
|
|
99
|
+
}).onConflictDoUpdate({
|
|
100
|
+
target: [
|
|
101
|
+
commentReactions.orgId,
|
|
102
|
+
commentReactions.commentId,
|
|
103
|
+
commentReactions.orgUserId
|
|
104
|
+
],
|
|
105
|
+
set: { emoji }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
async authorsOf(orgId, orgUserIds) {
|
|
109
|
+
if (orgUserIds.length === 0) return {};
|
|
110
|
+
const rows = await this.db.select({
|
|
111
|
+
...orgUserProfileColumns,
|
|
112
|
+
role: orgUsers.role
|
|
113
|
+
}).from(orgUsers).innerJoin(users, eq(users.id, orgUsers.userId)).leftJoin(user, eq(user.id, users.externalId)).where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)));
|
|
114
|
+
const byId = {};
|
|
115
|
+
for (const row of rows) byId[row.id] = {
|
|
116
|
+
id: row.id,
|
|
117
|
+
firstName: row.firstName,
|
|
118
|
+
lastName: row.lastName,
|
|
119
|
+
email: row.email,
|
|
120
|
+
image: row.image ?? null,
|
|
121
|
+
role: row.role
|
|
122
|
+
};
|
|
123
|
+
return byId;
|
|
124
|
+
}
|
|
125
|
+
async insertReport(orgId, report) {
|
|
126
|
+
const [row] = await this.db.insert(commentReports).values({
|
|
127
|
+
id: report.id,
|
|
128
|
+
orgId,
|
|
129
|
+
commentId: report.commentId,
|
|
130
|
+
orgUserId: report.orgUserId,
|
|
131
|
+
reason: report.reason,
|
|
132
|
+
resolvedAt: report.resolvedAt ? new Date(report.resolvedAt) : null,
|
|
133
|
+
createdAt: new Date(report.createdAt)
|
|
134
|
+
}).onConflictDoNothing().returning();
|
|
135
|
+
return row ?? null;
|
|
136
|
+
}
|
|
137
|
+
async listOpenReports(orgId, commentIds) {
|
|
138
|
+
if (commentIds.length === 0) return [];
|
|
139
|
+
return await this.db.select().from(commentReports).where(and(eq(commentReports.orgId, orgId), inArray(commentReports.commentId, commentIds), isNull(commentReports.resolvedAt))).orderBy(commentReports.createdAt);
|
|
140
|
+
}
|
|
141
|
+
async resolveReportsFor(orgId, commentId, resolvedAt) {
|
|
142
|
+
await this.db.update(commentReports).set({ resolvedAt }).where(and(eq(commentReports.orgId, orgId), eq(commentReports.commentId, commentId), isNull(commentReports.resolvedAt)));
|
|
143
|
+
}
|
|
144
|
+
/** The list's one join: comments to their activity, which carries both the
|
|
145
|
+
* course (denormalised, so the module never enters the query) and the title.
|
|
146
|
+
* The course filter and the row's activity title come out of that same pass,
|
|
147
|
+
* so neither costs an extra query. */
|
|
148
|
+
listRows(where) {
|
|
149
|
+
return this.db.select({
|
|
150
|
+
comment: comments,
|
|
151
|
+
courseId: activities.courseId,
|
|
152
|
+
activityTitle: activityTitleExpr
|
|
153
|
+
}).from(comments).innerJoin(activities, and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId))).where(where);
|
|
154
|
+
}
|
|
155
|
+
/** The same join and filters, counted. Separate from `listRows` because the
|
|
156
|
+
* total must ignore limit/offset. */
|
|
157
|
+
listTotal(where) {
|
|
158
|
+
return this.db.select({ total: sql`cast(count(*) as int)` }).from(comments).innerJoin(activities, and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId))).where(where);
|
|
159
|
+
}
|
|
160
|
+
async listComments(orgId, query) {
|
|
161
|
+
const conditions = [eq(comments.orgId, orgId)];
|
|
162
|
+
if (query.status) conditions.push(eq(comments.status, query.status));
|
|
163
|
+
if (query.courseId) conditions.push(eq(activities.courseId, query.courseId));
|
|
164
|
+
if (query.activityId) conditions.push(eq(comments.activityId, query.activityId));
|
|
165
|
+
if (query.orgUserId) conditions.push(eq(comments.orgUserId, query.orgUserId));
|
|
166
|
+
if (query.reported !== void 0) {
|
|
167
|
+
const hasOpenReport = sql`exists (
|
|
168
|
+
select 1 from ${commentReports} r
|
|
169
|
+
where r.org_id = ${comments.orgId}
|
|
170
|
+
and r.comment_id = ${comments.id}
|
|
171
|
+
and r.resolved_at is null
|
|
172
|
+
)`;
|
|
173
|
+
conditions.push(query.reported ? hasOpenReport : sql`not ${hasOpenReport}`);
|
|
174
|
+
}
|
|
175
|
+
if (query.search) conditions.push(ilike(comments.body, `%${query.search}%`));
|
|
176
|
+
const where = and(...conditions);
|
|
177
|
+
let orderBy;
|
|
178
|
+
if (query.sort) {
|
|
179
|
+
const isDesc = query.sort.startsWith("-");
|
|
180
|
+
const field = isDesc ? query.sort.slice(1) : query.sort;
|
|
181
|
+
const col = sortColumns[field] ?? comments.createdAt;
|
|
182
|
+
orderBy = isDesc ? desc(col) : asc(col);
|
|
183
|
+
} else orderBy = desc(comments.createdAt);
|
|
184
|
+
const rows = await this.listRows(where).orderBy(orderBy).limit(query.pageSize).offset((query.page - 1) * query.pageSize);
|
|
185
|
+
const [{ total } = { total: 0 }] = await this.listTotal(where);
|
|
186
|
+
return {
|
|
187
|
+
rows: rows.map((r) => ({
|
|
188
|
+
comment: toComment(r.comment),
|
|
189
|
+
courseId: r.courseId,
|
|
190
|
+
activityTitle: r.activityTitle
|
|
191
|
+
})),
|
|
192
|
+
total,
|
|
193
|
+
page: query.page,
|
|
194
|
+
pageSize: query.pageSize
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
//#endregion
|
|
199
|
+
export { DrizzleDiscussionRepository };
|
|
200
|
+
|
|
201
|
+
//# sourceMappingURL=discussion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discussion.js","names":[],"sources":["../../src/repositories/discussion.ts"],"sourcesContent":["import { and, asc, desc, eq, ilike, inArray, isNull, sql, type SQL } from 'drizzle-orm';\nimport type { NodePgDatabase } from 'drizzle-orm/node-postgres';\nimport type {\n AuthorRecord,\n CommentReactions,\n CommentWithContext,\n DiscussionRepository,\n} from '@headless-lms/core/discussion';\nimport type {\n Comment,\n CommentReport,\n ListCommentsQuery,\n Logger,\n Page,\n ReactionEmoji,\n Role,\n} from '@headless-lms/core/types';\nimport { commentReactions, commentReports, comments } from '../schema/index.js';\nimport { activities } from '../schema/content.js';\nimport { orgUsers } from '../schema/organizations.js';\nimport { users } from '../schema/identity.js';\nimport { user } from '../schema/better-auth.js';\nimport { orgUserProfileColumns } from './org-user-profile.js';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\n\ntype CommentRow = typeof comments.$inferSelect;\n\n/** The title lives in the activity's opaque settings blob; the moderation card\n * needs it to say \"Lesson 3\" rather than an id. */\nconst activityTitleExpr = sql<string>`coalesce(${activities.settings} ->> 'title', '')`;\n\n/** Sortable columns of the staff comment list, by the client-facing field name.\n * `activityTitle` sorts on the same expression the row is served from. */\nconst sortColumns = {\n createdAt: comments.createdAt,\n updatedAt: comments.updatedAt,\n status: comments.status,\n activityTitle: activityTitleExpr,\n} as const;\n\nfunction toComment(row: CommentRow): Comment {\n return {\n id: row.id,\n orgId: row.orgId,\n activityId: row.activityId,\n parentId: row.parentId ?? null,\n orgUserId: row.orgUserId,\n body: row.body,\n status: row.status,\n removedBy: row.removedBy ?? null,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nexport class DrizzleDiscussionRepository implements DiscussionRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async insertComment(orgId: string, comment: Comment): Promise<Comment> {\n const [row] = await this.db\n .insert(comments)\n .values({\n id: comment.id,\n orgId,\n activityId: comment.activityId,\n parentId: comment.parentId,\n orgUserId: comment.orgUserId,\n body: comment.body,\n status: comment.status,\n removedBy: comment.removedBy,\n createdAt: new Date(comment.createdAt),\n updatedAt: new Date(comment.updatedAt),\n })\n .returning();\n return toComment(row!);\n }\n\n async findComment(orgId: string, id: string): Promise<Comment | null> {\n const [row] = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .limit(1);\n return row ? toComment(row) : null;\n }\n\n async updateComment(\n orgId: string,\n id: string,\n patch: Partial<Pick<Comment, 'body' | 'status' | 'removedBy' | 'updatedAt'>>,\n ): Promise<Comment | null> {\n const [row] = await this.db\n .update(comments)\n .set({\n ...('body' in patch ? { body: patch.body } : {}),\n ...('status' in patch ? { status: patch.status } : {}),\n ...('removedBy' in patch ? { removedBy: patch.removedBy ?? null } : {}),\n })\n .where(and(eq(comments.orgId, orgId), eq(comments.id, id)))\n .returning();\n return row ? toComment(row) : null;\n }\n\n async listByActivity(orgId: string, activityId: string): Promise<Comment[]> {\n const rows = await this.db\n .select()\n .from(comments)\n .where(and(eq(comments.orgId, orgId), eq(comments.activityId, activityId)))\n .orderBy(comments.createdAt);\n return rows.map(toComment);\n }\n\n async reactionsOf(\n orgId: string,\n commentIds: string[],\n viewerId: string,\n ): Promise<Record<string, CommentReactions>> {\n if (commentIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({\n commentId: commentReactions.commentId,\n emoji: commentReactions.emoji,\n count: sql<number>`cast(count(*) as int)`,\n mine: sql<boolean>`bool_or(${commentReactions.orgUserId} = ${viewerId})`,\n })\n .from(commentReactions)\n .where(\n and(eq(commentReactions.orgId, orgId), inArray(commentReactions.commentId, commentIds)),\n )\n .groupBy(commentReactions.commentId, commentReactions.emoji);\n\n const byComment: Record<string, CommentReactions> = {};\n for (const row of rows) {\n const entry = (byComment[row.commentId] ??= { reactions: {} });\n entry.reactions[row.emoji] = row.count;\n if (row.mine) {\n entry.viewerReaction = row.emoji;\n }\n }\n return byComment;\n }\n\n async setReaction(\n orgId: string,\n commentId: string,\n orgUserId: string,\n emoji: ReactionEmoji | null,\n ): Promise<void> {\n if (emoji === null) {\n await this.db\n .delete(commentReactions)\n .where(\n and(\n eq(commentReactions.orgId, orgId),\n eq(commentReactions.commentId, commentId),\n eq(commentReactions.orgUserId, orgUserId),\n ),\n );\n return;\n }\n await this.db\n .insert(commentReactions)\n .values({ orgId, commentId, orgUserId, emoji })\n .onConflictDoUpdate({\n target: [commentReactions.orgId, commentReactions.commentId, commentReactions.orgUserId],\n set: { emoji },\n });\n }\n\n async authorsOf(orgId: string, orgUserIds: string[]): Promise<Record<string, AuthorRecord>> {\n if (orgUserIds.length === 0) {\n return {};\n }\n const rows = await this.db\n .select({ ...orgUserProfileColumns, role: orgUsers.role })\n .from(orgUsers)\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .leftJoin(user, eq(user.id, users.externalId))\n .where(and(eq(orgUsers.orgId, orgId), inArray(orgUsers.id, orgUserIds)));\n\n const byId: Record<string, AuthorRecord> = {};\n for (const row of rows) {\n byId[row.id] = {\n id: row.id,\n firstName: row.firstName,\n lastName: row.lastName,\n email: row.email,\n image: row.image ?? null,\n role: row.role as Role,\n };\n }\n return byId;\n }\n\n async insertReport(orgId: string, report: CommentReport): Promise<CommentReport | null> {\n const [row] = await this.db\n .insert(commentReports)\n .values({\n id: report.id,\n orgId,\n commentId: report.commentId,\n orgUserId: report.orgUserId,\n reason: report.reason,\n resolvedAt: report.resolvedAt ? new Date(report.resolvedAt) : null,\n createdAt: new Date(report.createdAt),\n })\n .onConflictDoNothing()\n .returning();\n return row ?? null;\n }\n\n async listOpenReports(orgId: string, commentIds: string[]): Promise<CommentReport[]> {\n if (commentIds.length === 0) {\n return [];\n }\n const rows = await this.db\n .select()\n .from(commentReports)\n .where(\n and(\n eq(commentReports.orgId, orgId),\n inArray(commentReports.commentId, commentIds),\n isNull(commentReports.resolvedAt),\n ),\n )\n .orderBy(commentReports.createdAt);\n return rows;\n }\n\n async resolveReportsFor(orgId: string, commentId: string, resolvedAt: Date): Promise<void> {\n await this.db\n .update(commentReports)\n .set({ resolvedAt })\n .where(\n and(\n eq(commentReports.orgId, orgId),\n eq(commentReports.commentId, commentId),\n isNull(commentReports.resolvedAt),\n ),\n );\n }\n\n /** The list's one join: comments to their activity, which carries both the\n * course (denormalised, so the module never enters the query) and the title.\n * The course filter and the row's activity title come out of that same pass,\n * so neither costs an extra query. */\n private listRows(where: SQL | undefined) {\n return this.db\n .select({\n comment: comments,\n courseId: activities.courseId,\n activityTitle: activityTitleExpr,\n })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n /** The same join and filters, counted. Separate from `listRows` because the\n * total must ignore limit/offset. */\n private listTotal(where: SQL | undefined) {\n return this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(comments)\n .innerJoin(\n activities,\n and(eq(activities.orgId, comments.orgId), eq(activities.id, comments.activityId)),\n )\n .where(where);\n }\n\n async listComments(orgId: string, query: ListCommentsQuery): Promise<Page<CommentWithContext>> {\n const conditions: SQL[] = [eq(comments.orgId, orgId)];\n if (query.status) {\n conditions.push(eq(comments.status, query.status));\n }\n if (query.courseId) {\n conditions.push(eq(activities.courseId, query.courseId));\n }\n if (query.activityId) {\n conditions.push(eq(comments.activityId, query.activityId));\n }\n if (query.orgUserId) {\n conditions.push(eq(comments.orgUserId, query.orgUserId));\n }\n if (query.reported !== undefined) {\n // Correlated EXISTS rather than a join to comment_reports: a comment with\n // three open reports must stay one row.\n const hasOpenReport = sql`exists (\n select 1 from ${commentReports} r\n where r.org_id = ${comments.orgId}\n and r.comment_id = ${comments.id}\n and r.resolved_at is null\n )`;\n conditions.push(query.reported ? hasOpenReport : sql`not ${hasOpenReport}`);\n }\n if (query.search) {\n conditions.push(ilike(comments.body, `%${query.search}%`));\n }\n const where = and(...conditions);\n\n // Sort: `-field` for descending; default to newest first, which is the\n // order a moderator works in.\n let orderBy: SQL;\n if (query.sort) {\n const isDesc = query.sort.startsWith('-');\n const field = (isDesc ? query.sort.slice(1) : query.sort) as keyof typeof sortColumns;\n const col = sortColumns[field] ?? comments.createdAt;\n orderBy = isDesc ? desc(col) : asc(col);\n } else {\n orderBy = desc(comments.createdAt);\n }\n\n const rows = await this.listRows(where)\n .orderBy(orderBy)\n .limit(query.pageSize)\n .offset((query.page - 1) * query.pageSize);\n\n const [{ total } = { total: 0 }] = await this.listTotal(where);\n\n return {\n rows: rows.map((r) => ({\n comment: toComment(r.comment),\n courseId: r.courseId,\n activityTitle: r.activityTitle,\n })),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { DbExecutor } from "../client.js";
|
|
2
|
+
import { Entitlement, EntitlementsQuery, GrantEntitlementInput, Logger, Page } from "@headless-lms/core/types";
|
|
3
|
+
import { EntitlementsRepository } from "@headless-lms/core/entitlements";
|
|
4
|
+
//#region src/repositories/entitlements.d.ts
|
|
5
|
+
declare class DrizzleEntitlementsRepository implements EntitlementsRepository {
|
|
6
|
+
private readonly db;
|
|
7
|
+
private readonly logger;
|
|
8
|
+
constructor(db: DbExecutor, logger?: Logger);
|
|
9
|
+
/** entitlements → org_users + content_items (type) + one LEFT JOIN per
|
|
10
|
+
* concrete content table (title). */
|
|
11
|
+
private joined;
|
|
12
|
+
list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>>;
|
|
13
|
+
insert(orgId: string, input: GrantEntitlementInput): Promise<Entitlement>;
|
|
14
|
+
setStatus(orgId: string, id: string, status: 'active' | 'revoked'): Promise<Entitlement | null>;
|
|
15
|
+
/** Existence check scoped to the course case: same status predicate as
|
|
16
|
+
* `list` (revoked never counts; an elapsed expiry reads as expired, not
|
|
17
|
+
* active), joined to content_items and constrained to type = 'course' so a
|
|
18
|
+
* grant on some other content type can never be mistaken for course access. */
|
|
19
|
+
hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { DrizzleEntitlementsRepository };
|
|
23
|
+
//# sourceMappingURL=entitlements.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { users } from "../schema/identity.js";
|
|
2
|
+
import { orgUsers } from "../schema/organizations.js";
|
|
3
|
+
import { contentItems, courses, downloads } from "../schema/content.js";
|
|
4
|
+
import { entitlements } from "../schema/entitlements.js";
|
|
5
|
+
import "../schema/index.js";
|
|
6
|
+
import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm";
|
|
7
|
+
import { noopLogger } from "@headless-lms/core/shared/logger";
|
|
8
|
+
//#region src/repositories/entitlements.ts
|
|
9
|
+
const derivedStatus = sql`case
|
|
10
|
+
when ${entitlements.status} = 'revoked' then 'revoked'
|
|
11
|
+
when ${entitlements.expiresAt} is not null and ${entitlements.expiresAt} < now() then 'expired'
|
|
12
|
+
else 'active'
|
|
13
|
+
end`;
|
|
14
|
+
const contentTitle = sql`coalesce(${courses.title}, ${downloads.title})`;
|
|
15
|
+
const sortColumns = {
|
|
16
|
+
firstName: users.firstName,
|
|
17
|
+
lastName: users.lastName,
|
|
18
|
+
email: users.email,
|
|
19
|
+
contentTitle,
|
|
20
|
+
status: derivedStatus,
|
|
21
|
+
grantedAt: entitlements.grantedAt,
|
|
22
|
+
expiresAt: entitlements.expiresAt,
|
|
23
|
+
source: entitlements.source
|
|
24
|
+
};
|
|
25
|
+
const selection = {
|
|
26
|
+
orgId: entitlements.orgId,
|
|
27
|
+
id: entitlements.id,
|
|
28
|
+
orgUserId: entitlements.orgUserId,
|
|
29
|
+
contentId: entitlements.contentId,
|
|
30
|
+
status: entitlements.status,
|
|
31
|
+
source: entitlements.source,
|
|
32
|
+
grantedAt: entitlements.grantedAt,
|
|
33
|
+
expiresAt: entitlements.expiresAt
|
|
34
|
+
};
|
|
35
|
+
function toEntitlement(row) {
|
|
36
|
+
return {
|
|
37
|
+
orgId: row.orgId,
|
|
38
|
+
id: row.id,
|
|
39
|
+
orgUserId: row.orgUserId,
|
|
40
|
+
contentId: row.contentId,
|
|
41
|
+
status: row.status,
|
|
42
|
+
source: row.source,
|
|
43
|
+
grantedAt: row.grantedAt,
|
|
44
|
+
expiresAt: row.expiresAt
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
var DrizzleEntitlementsRepository = class {
|
|
48
|
+
db;
|
|
49
|
+
logger;
|
|
50
|
+
constructor(db, logger = noopLogger) {
|
|
51
|
+
this.db = db;
|
|
52
|
+
this.logger = logger;
|
|
53
|
+
}
|
|
54
|
+
/** entitlements → org_users + content_items (type) + one LEFT JOIN per
|
|
55
|
+
* concrete content table (title). */
|
|
56
|
+
joined(where) {
|
|
57
|
+
return this.db.select(selection).from(entitlements).innerJoin(orgUsers, and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId))).innerJoin(users, eq(users.id, orgUsers.userId)).innerJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).leftJoin(courses, and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId))).leftJoin(downloads, and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId))).where(where);
|
|
58
|
+
}
|
|
59
|
+
async list(orgId, query) {
|
|
60
|
+
const conditions = [eq(entitlements.orgId, orgId)];
|
|
61
|
+
if (query.status) conditions.push(sql`${derivedStatus} = ${query.status}`);
|
|
62
|
+
if (query.source) conditions.push(eq(entitlements.source, query.source));
|
|
63
|
+
if (query.orgUserId) conditions.push(eq(entitlements.orgUserId, query.orgUserId));
|
|
64
|
+
if (query.contentId) conditions.push(eq(entitlements.contentId, query.contentId));
|
|
65
|
+
if (query.type) conditions.push(eq(contentItems.type, query.type));
|
|
66
|
+
if (query.search) {
|
|
67
|
+
const pattern = `%${query.search}%`;
|
|
68
|
+
conditions.push(or(ilike(users.firstName, pattern), ilike(users.lastName, pattern), ilike(users.email, pattern), ilike(contentTitle, pattern)));
|
|
69
|
+
}
|
|
70
|
+
const where = and(...conditions);
|
|
71
|
+
let orderBy;
|
|
72
|
+
if (query.sort) {
|
|
73
|
+
const isDesc = query.sort.startsWith("-");
|
|
74
|
+
const field = isDesc ? query.sort.slice(1) : query.sort;
|
|
75
|
+
const col = sortColumns[field] ?? entitlements.grantedAt;
|
|
76
|
+
orderBy = isDesc ? desc(col) : asc(col);
|
|
77
|
+
} else orderBy = desc(entitlements.grantedAt);
|
|
78
|
+
const offset = (query.page - 1) * query.pageSize;
|
|
79
|
+
const rows = await this.joined(where).orderBy(orderBy).limit(query.pageSize).offset(offset);
|
|
80
|
+
const [{ total } = { total: 0 }] = await this.db.select({ total: sql`cast(count(*) as int)` }).from(entitlements).innerJoin(orgUsers, and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId))).innerJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).leftJoin(courses, and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId))).leftJoin(downloads, and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId))).where(where);
|
|
81
|
+
return {
|
|
82
|
+
rows: rows.map(toEntitlement),
|
|
83
|
+
total,
|
|
84
|
+
page: query.page,
|
|
85
|
+
pageSize: query.pageSize
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async insert(orgId, input) {
|
|
89
|
+
const [row] = await this.db.insert(entitlements).values({
|
|
90
|
+
orgId,
|
|
91
|
+
orgUserId: input.orgUserId,
|
|
92
|
+
contentId: input.contentId,
|
|
93
|
+
status: "active",
|
|
94
|
+
source: "manual",
|
|
95
|
+
grantedAt: /* @__PURE__ */ new Date(),
|
|
96
|
+
expiresAt: input.expiresAt
|
|
97
|
+
}).onConflictDoUpdate({
|
|
98
|
+
target: [
|
|
99
|
+
entitlements.orgId,
|
|
100
|
+
entitlements.orgUserId,
|
|
101
|
+
entitlements.contentId
|
|
102
|
+
],
|
|
103
|
+
set: {
|
|
104
|
+
status: "active",
|
|
105
|
+
source: "manual",
|
|
106
|
+
grantedAt: /* @__PURE__ */ new Date(),
|
|
107
|
+
expiresAt: input.expiresAt
|
|
108
|
+
}
|
|
109
|
+
}).returning(selection);
|
|
110
|
+
if (!row) throw new Error("failed to insert entitlement");
|
|
111
|
+
return toEntitlement(row);
|
|
112
|
+
}
|
|
113
|
+
async setStatus(orgId, id, status) {
|
|
114
|
+
const [row] = await this.db.update(entitlements).set({ status }).where(and(eq(entitlements.orgId, orgId), eq(entitlements.id, id))).returning(selection);
|
|
115
|
+
if (!row) return null;
|
|
116
|
+
return toEntitlement(row);
|
|
117
|
+
}
|
|
118
|
+
/** Existence check scoped to the course case: same status predicate as
|
|
119
|
+
* `list` (revoked never counts; an elapsed expiry reads as expired, not
|
|
120
|
+
* active), joined to content_items and constrained to type = 'course' so a
|
|
121
|
+
* grant on some other content type can never be mistaken for course access. */
|
|
122
|
+
async hasCourseAccess(orgId, orgUserId, courseId) {
|
|
123
|
+
return (await this.db.select({ id: entitlements.id }).from(entitlements).innerJoin(contentItems, and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId))).where(and(eq(entitlements.orgId, orgId), eq(entitlements.orgUserId, orgUserId), eq(entitlements.contentId, courseId), eq(contentItems.type, "course"), sql`${derivedStatus} = 'active'`)).limit(1)).length > 0;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
//#endregion
|
|
127
|
+
export { DrizzleEntitlementsRepository };
|
|
128
|
+
|
|
129
|
+
//# sourceMappingURL=entitlements.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entitlements.js","names":[],"sources":["../../src/repositories/entitlements.ts"],"sourcesContent":["import { and, asc, desc, eq, ilike, or, sql, type SQL } from 'drizzle-orm';\nimport type { DbExecutor } from '../client.js';\nimport type { EntitlementsRepository } from '@headless-lms/core/entitlements';\nimport type {\n Entitlement,\n EntitlementStatus,\n EntitlementsQuery,\n GrantEntitlementInput,\n Logger,\n Page,\n} from '@headless-lms/core/types';\nimport { entitlements, orgUsers, users } from '../schema/index.js';\nimport { contentItems, courses, downloads } from '../schema/content.js';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\n\ntype ResolvedStatus = 'active' | 'expired' | 'revoked';\n\n// CASE expression: revoked beats everything; otherwise an elapsed expiry reads as\n// expired; otherwise active.\nconst derivedStatus = sql<ResolvedStatus>`case\n when ${entitlements.status} = 'revoked' then 'revoked'\n when ${entitlements.expiresAt} is not null and ${entitlements.expiresAt} < now() then 'expired'\n else 'active'\nend`;\n\n// Display name of the granted content, whatever its type. One LEFT JOIN per\n// concrete content table; exactly one hits per row (type-pinned FKs), so the\n// COALESCE picks the single non-null title. Extended per new content type.\nconst contentTitle = sql<string>`coalesce(${courses.title}, ${downloads.title})`;\n\n// Sortable columns by the client-facing field name. `status` sorts on the derived\n// expression so the ordering matches the displayed value; `contentTitle` on the\n// coalesced join expression.\nconst sortColumns = {\n firstName: users.firstName,\n lastName: users.lastName,\n email: users.email,\n contentTitle,\n status: derivedStatus,\n grantedAt: entitlements.grantedAt,\n expiresAt: entitlements.expiresAt,\n source: entitlements.source,\n} as const;\n\nconst selection = {\n orgId: entitlements.orgId,\n id: entitlements.id,\n orgUserId: entitlements.orgUserId,\n contentId: entitlements.contentId,\n status: entitlements.status,\n source: entitlements.source,\n grantedAt: entitlements.grantedAt,\n expiresAt: entitlements.expiresAt,\n} as const;\n\ninterface Row {\n orgId: string;\n id: string;\n orgUserId: string;\n contentId: string;\n status: EntitlementStatus;\n source: string;\n grantedAt: Date;\n expiresAt: Date | null;\n}\n\nfunction toEntitlement(row: Row): Entitlement {\n return {\n orgId: row.orgId,\n id: row.id,\n orgUserId: row.orgUserId,\n contentId: row.contentId,\n status: row.status,\n source: row.source,\n grantedAt: row.grantedAt,\n expiresAt: row.expiresAt,\n };\n}\n\nexport class DrizzleEntitlementsRepository implements EntitlementsRepository {\n constructor(\n private readonly db: DbExecutor,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n /** entitlements → org_users + content_items (type) + one LEFT JOIN per\n * concrete content table (title). */\n private joined(where: SQL | undefined) {\n return this.db\n .select(selection)\n .from(entitlements)\n .innerJoin(\n orgUsers,\n and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId)),\n )\n .innerJoin(users, eq(users.id, orgUsers.userId))\n .innerJoin(\n contentItems,\n and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId)),\n )\n .leftJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .leftJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(where);\n }\n\n async list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>> {\n const conditions: SQL[] = [eq(entitlements.orgId, orgId)];\n if (query.status) {\n conditions.push(sql`${derivedStatus} = ${query.status}`);\n }\n if (query.source) {\n conditions.push(eq(entitlements.source, query.source));\n }\n if (query.orgUserId) {\n conditions.push(eq(entitlements.orgUserId, query.orgUserId));\n }\n if (query.contentId) {\n conditions.push(eq(entitlements.contentId, query.contentId));\n }\n if (query.type) {\n conditions.push(eq(contentItems.type, query.type));\n }\n if (query.search) {\n const pattern = `%${query.search}%`;\n conditions.push(\n or(\n ilike(users.firstName, pattern),\n ilike(users.lastName, pattern),\n ilike(users.email, pattern),\n ilike(contentTitle, pattern),\n ) as SQL,\n );\n }\n const where = and(...conditions);\n\n // Sort: `-field` for descending; default to most-recently granted first.\n let orderBy: SQL;\n if (query.sort) {\n const isDesc = query.sort.startsWith('-');\n const field = (isDesc ? query.sort.slice(1) : query.sort) as keyof typeof sortColumns;\n const col = sortColumns[field] ?? entitlements.grantedAt;\n orderBy = isDesc ? desc(col) : asc(col);\n } else {\n orderBy = desc(entitlements.grantedAt);\n }\n\n const offset = (query.page - 1) * query.pageSize;\n\n const rows = await this.joined(where).orderBy(orderBy).limit(query.pageSize).offset(offset);\n\n const [{ total } = { total: 0 }] = await this.db\n .select({ total: sql<number>`cast(count(*) as int)` })\n .from(entitlements)\n .innerJoin(\n orgUsers,\n and(eq(orgUsers.orgId, entitlements.orgId), eq(orgUsers.id, entitlements.orgUserId)),\n )\n .innerJoin(\n contentItems,\n and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId)),\n )\n .leftJoin(\n courses,\n and(eq(courses.orgId, entitlements.orgId), eq(courses.id, entitlements.contentId)),\n )\n .leftJoin(\n downloads,\n and(eq(downloads.orgId, entitlements.orgId), eq(downloads.id, entitlements.contentId)),\n )\n .where(where);\n\n return {\n rows: rows.map(toEntitlement),\n total,\n page: query.page,\n pageSize: query.pageSize,\n };\n }\n\n async insert(orgId: string, input: GrantEntitlementInput): Promise<Entitlement> {\n const [row] = await this.db\n .insert(entitlements)\n .values({\n orgId,\n orgUserId: input.orgUserId,\n contentId: input.contentId,\n status: 'active',\n source: 'manual',\n grantedAt: new Date(),\n expiresAt: input.expiresAt,\n })\n .onConflictDoUpdate({\n target: [entitlements.orgId, entitlements.orgUserId, entitlements.contentId],\n set: {\n status: 'active',\n source: 'manual',\n grantedAt: new Date(),\n expiresAt: input.expiresAt,\n },\n })\n .returning(selection);\n if (!row) {\n throw new Error('failed to insert entitlement');\n }\n return toEntitlement(row);\n }\n\n async setStatus(\n orgId: string,\n id: string,\n status: 'active' | 'revoked',\n ): Promise<Entitlement | null> {\n const [row] = await this.db\n .update(entitlements)\n .set({ status })\n .where(and(eq(entitlements.orgId, orgId), eq(entitlements.id, id)))\n .returning(selection);\n if (!row) {\n return null;\n }\n return toEntitlement(row);\n }\n\n /** Existence check scoped to the course case: same status predicate as\n * `list` (revoked never counts; an elapsed expiry reads as expired, not\n * active), joined to content_items and constrained to type = 'course' so a\n * grant on some other content type can never be mistaken for course access. */\n async hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean> {\n const rows = await this.db\n .select({ id: entitlements.id })\n .from(entitlements)\n .innerJoin(\n contentItems,\n and(eq(contentItems.orgId, entitlements.orgId), eq(contentItems.id, entitlements.contentId)),\n )\n .where(\n and(\n eq(entitlements.orgId, orgId),\n eq(entitlements.orgUserId, orgUserId),\n eq(entitlements.contentId, courseId),\n eq(contentItems.type, 'course'),\n sql`${derivedStatus} = 'active'`,\n ),\n )\n .limit(1);\n return rows.length > 0;\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { CreateUserInput, Logger, UpdateUserInput, User } from "@headless-lms/core/types";
|
|
3
|
+
import { IdentityRepository } from "@headless-lms/core/identity";
|
|
4
|
+
//#region src/repositories/identity.d.ts
|
|
5
|
+
declare class DrizzleIdentityRepository implements IdentityRepository {
|
|
6
|
+
private readonly db;
|
|
7
|
+
private readonly logger;
|
|
8
|
+
constructor(db: NodePgDatabase, logger?: Logger);
|
|
9
|
+
insertUser(input: CreateUserInput): Promise<User>;
|
|
10
|
+
updateUser(id: string, input: UpdateUserInput): Promise<User | null>;
|
|
11
|
+
findUserById(id: string): Promise<User | null>;
|
|
12
|
+
findUserByExternalId(externalId: string): Promise<User | null>;
|
|
13
|
+
findUserByEmail(email: string): Promise<User | null>;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { DrizzleIdentityRepository };
|
|
17
|
+
//# sourceMappingURL=identity.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { users } from "../schema/identity.js";
|
|
2
|
+
import { eq, sql } from "drizzle-orm";
|
|
3
|
+
import { noopLogger } from "@headless-lms/core/shared/logger";
|
|
4
|
+
//#region src/repositories/identity.ts
|
|
5
|
+
var DrizzleIdentityRepository = class {
|
|
6
|
+
db;
|
|
7
|
+
logger;
|
|
8
|
+
constructor(db, logger = noopLogger) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
}
|
|
12
|
+
async insertUser(input) {
|
|
13
|
+
const [row] = await this.db.insert(users).values({
|
|
14
|
+
id: input.id,
|
|
15
|
+
externalId: input.externalId ?? null,
|
|
16
|
+
email: input.email,
|
|
17
|
+
firstName: input.firstName ?? null,
|
|
18
|
+
lastName: input.lastName ?? null
|
|
19
|
+
}).returning();
|
|
20
|
+
if (!row) throw new Error("failed to insert user");
|
|
21
|
+
return row;
|
|
22
|
+
}
|
|
23
|
+
async updateUser(id, input) {
|
|
24
|
+
if (Object.keys(input).length === 0) return this.findUserById(id);
|
|
25
|
+
const [row] = await this.db.update(users).set(input).where(eq(users.id, id)).returning();
|
|
26
|
+
return row ?? null;
|
|
27
|
+
}
|
|
28
|
+
async findUserById(id) {
|
|
29
|
+
const [row] = await this.db.select().from(users).where(eq(users.id, id)).limit(1);
|
|
30
|
+
return row ?? null;
|
|
31
|
+
}
|
|
32
|
+
async findUserByExternalId(externalId) {
|
|
33
|
+
const [row] = await this.db.select().from(users).where(eq(users.externalId, externalId)).limit(1);
|
|
34
|
+
return row ?? null;
|
|
35
|
+
}
|
|
36
|
+
async findUserByEmail(email) {
|
|
37
|
+
const [row] = await this.db.select().from(users).where(sql`lower(${users.email}) = lower(${email})`).limit(1);
|
|
38
|
+
return row ?? null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
//#endregion
|
|
42
|
+
export { DrizzleIdentityRepository };
|
|
43
|
+
|
|
44
|
+
//# sourceMappingURL=identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.js","names":[],"sources":["../../src/repositories/identity.ts"],"sourcesContent":["// identity — Drizzle repository (implements the core outbound port).\n//\n// The person only. A person's link to an organization lives in the\n// organizations context (`org_users`), not here.\nimport { eq, sql } from 'drizzle-orm';\nimport type { NodePgDatabase } from 'drizzle-orm/node-postgres';\nimport type { IdentityRepository } from '@headless-lms/core/identity';\nimport type { CreateUserInput, Logger, UpdateUserInput, User } from '@headless-lms/core/types';\nimport { users } from '../schema/identity.js';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\n\nexport class DrizzleIdentityRepository implements IdentityRepository {\n constructor(\n private readonly db: NodePgDatabase,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async insertUser(input: CreateUserInput): Promise<User> {\n const [row] = await this.db\n .insert(users)\n .values({\n id: input.id,\n externalId: input.externalId ?? null,\n email: input.email,\n firstName: input.firstName ?? null,\n lastName: input.lastName ?? null,\n })\n .returning();\n if (!row) {\n throw new Error('failed to insert user');\n }\n return row;\n }\n\n async updateUser(\n id: string,\n input: UpdateUserInput,\n ): Promise<User | null> {\n // An empty patch would be an UPDATE with no SET — read the row back instead.\n if (Object.keys(input).length === 0) {\n return this.findUserById(id);\n }\n const [row] = await this.db.update(users)\n .set(input)\n .where(eq(users.id, id))\n .returning();\n return row ?? null;\n }\n\n async findUserById(id: string): Promise<User | null> {\n const [row] = await this.db.select().from(users).where(eq(users.id, id)).limit(1);\n return row ?? null;\n }\n\n async findUserByExternalId(externalId: string): Promise<User | null> {\n const [row] = await this.db\n .select()\n .from(users)\n .where(eq(users.externalId, externalId))\n .limit(1);\n return row ?? null;\n }\n\n // Case-insensitive: the address comes from whatever an admin typed into an\n // invite, the row from whatever the auth provider stored.\n async findUserByEmail(email: string): Promise<User | null> {\n const [row] = await this.db\n .select()\n .from(users)\n .where(sql`lower(${users.email}) = lower(${email})`)\n .limit(1);\n return row ?? null;\n }\n}\n"],"mappings":";;;;AAWA,IAAa,4BAAb,MAAqE;CAEhD;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,WAAW,OAAuC;EACtD,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,KAAK,CAAC,CACb,OAAO;GACN,IAAI,MAAM;GACV,YAAY,MAAM,cAAc;GAChC,OAAO,MAAM;GACb,WAAW,MAAM,aAAa;GAC9B,UAAU,MAAM,YAAY;EAC9B,CAAC,CAAC,CACD,UAAU;EACb,IAAI,CAAC,KACH,MAAM,IAAI,MAAM,uBAAuB;EAEzC,OAAO;CACT;CAEA,MAAM,WACJ,IACA,OACsB;EAEtB,IAAI,OAAO,KAAK,KAAK,CAAC,CAAC,WAAW,GAChC,OAAO,KAAK,aAAa,EAAE;EAE7B,MAAM,CAAC,OAAO,MAAM,KAAK,GAAG,OAAO,KAAK,CAAC,CACtC,IAAI,KAAK,CAAC,CACV,MAAM,GAAG,MAAM,IAAI,EAAE,CAAC,CAAC,CACvB,UAAU;EACb,OAAO,OAAO;CAChB;CAEA,MAAM,aAAa,IAAkC;EACnD,MAAM,CAAC,OAAO,MAAM,KAAK,GAAG,OAAO,CAAC,CAAC,KAAK,KAAK,CAAC,CAAC,MAAM,GAAG,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;EAChF,OAAO,OAAO;CAChB;CAEA,MAAM,qBAAqB,YAA0C;EACnE,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,CAAC,CACR,KAAK,KAAK,CAAC,CACX,MAAM,GAAG,MAAM,YAAY,UAAU,CAAC,CAAC,CACvC,MAAM,CAAC;EACV,OAAO,OAAO;CAChB;CAIA,MAAM,gBAAgB,OAAqC;EACzD,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,CAAC,CACR,KAAK,KAAK,CAAC,CACX,MAAM,GAAG,SAAS,MAAM,MAAM,YAAY,MAAM,EAAE,CAAC,CACnD,MAAM,CAAC;EACV,OAAO,OAAO;CAChB;AACF"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { DbExecutor } from "../client.js";
|
|
2
|
+
import { Logger } from "@headless-lms/core/shared/ports";
|
|
3
|
+
import { Connection, ConnectionsRepository } from "@headless-lms/core/integrations";
|
|
4
|
+
//#region src/repositories/integrations.d.ts
|
|
5
|
+
declare class DrizzleConnectionsRepository implements ConnectionsRepository {
|
|
6
|
+
private readonly db;
|
|
7
|
+
private readonly logger;
|
|
8
|
+
constructor(db: DbExecutor, logger?: Logger);
|
|
9
|
+
insert(orgId: string, connection: Connection): Promise<Connection>;
|
|
10
|
+
findById(orgId: string, id: string): Promise<Connection | null>;
|
|
11
|
+
findByIntegration(orgId: string, integrationId: string): Promise<Connection | null>;
|
|
12
|
+
list(orgId: string): Promise<Connection[]>;
|
|
13
|
+
update(orgId: string, id: string, patch: Partial<Pick<Connection, 'config' | 'active' | 'updatedAt'>>): Promise<Connection | null>;
|
|
14
|
+
delete(orgId: string, id: string): Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { DrizzleConnectionsRepository };
|
|
18
|
+
//# sourceMappingURL=integrations.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { connections } from "../schema/integrations.js";
|
|
2
|
+
import { and, asc, eq } from "drizzle-orm";
|
|
3
|
+
import { noopLogger } from "@headless-lms/core/shared/logger";
|
|
4
|
+
//#region src/repositories/integrations.ts
|
|
5
|
+
function toConnection(row) {
|
|
6
|
+
return {
|
|
7
|
+
orgId: row.orgId,
|
|
8
|
+
id: row.id,
|
|
9
|
+
integrationId: row.integrationId,
|
|
10
|
+
config: row.config,
|
|
11
|
+
active: row.active,
|
|
12
|
+
credentialRef: row.credentialRef,
|
|
13
|
+
createdAt: row.createdAt,
|
|
14
|
+
updatedAt: row.updatedAt
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
var DrizzleConnectionsRepository = class {
|
|
18
|
+
db;
|
|
19
|
+
logger;
|
|
20
|
+
constructor(db, logger = noopLogger) {
|
|
21
|
+
this.db = db;
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
}
|
|
24
|
+
async insert(orgId, connection) {
|
|
25
|
+
if (connection.orgId !== orgId) throw new Error("connection org mismatch");
|
|
26
|
+
const [row] = await this.db.insert(connections).values({
|
|
27
|
+
orgId: connection.orgId,
|
|
28
|
+
id: connection.id,
|
|
29
|
+
integrationId: connection.integrationId,
|
|
30
|
+
config: connection.config,
|
|
31
|
+
active: connection.active,
|
|
32
|
+
credentialRef: connection.credentialRef,
|
|
33
|
+
createdAt: new Date(connection.createdAt),
|
|
34
|
+
updatedAt: new Date(connection.updatedAt)
|
|
35
|
+
}).returning();
|
|
36
|
+
if (!row) throw new Error("failed to insert connection");
|
|
37
|
+
return toConnection(row);
|
|
38
|
+
}
|
|
39
|
+
async findById(orgId, id) {
|
|
40
|
+
const [row] = await this.db.select().from(connections).where(and(eq(connections.orgId, orgId), eq(connections.id, id))).limit(1);
|
|
41
|
+
return row ? toConnection(row) : null;
|
|
42
|
+
}
|
|
43
|
+
async findByIntegration(orgId, integrationId) {
|
|
44
|
+
const [row] = await this.db.select().from(connections).where(and(eq(connections.orgId, orgId), eq(connections.integrationId, integrationId))).limit(1);
|
|
45
|
+
return row ? toConnection(row) : null;
|
|
46
|
+
}
|
|
47
|
+
async list(orgId) {
|
|
48
|
+
return (await this.db.select().from(connections).where(eq(connections.orgId, orgId)).orderBy(asc(connections.integrationId))).map(toConnection);
|
|
49
|
+
}
|
|
50
|
+
async update(orgId, id, patch) {
|
|
51
|
+
const [row] = await this.db.update(connections).set({
|
|
52
|
+
...patch.config !== void 0 ? { config: patch.config } : {},
|
|
53
|
+
...patch.active !== void 0 ? { active: patch.active } : {},
|
|
54
|
+
...patch.updatedAt !== void 0 ? { updatedAt: new Date(patch.updatedAt) } : {}
|
|
55
|
+
}).where(and(eq(connections.orgId, orgId), eq(connections.id, id))).returning();
|
|
56
|
+
return row ? toConnection(row) : null;
|
|
57
|
+
}
|
|
58
|
+
async delete(orgId, id) {
|
|
59
|
+
return (await this.db.delete(connections).where(and(eq(connections.orgId, orgId), eq(connections.id, id))).returning({ id: connections.id })).length > 0;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
//#endregion
|
|
63
|
+
export { DrizzleConnectionsRepository };
|
|
64
|
+
|
|
65
|
+
//# sourceMappingURL=integrations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"integrations.js","names":[],"sources":["../../src/repositories/integrations.ts"],"sourcesContent":["// integrations — Drizzle repository (implements the core outbound port).\nimport { and, asc, eq } from 'drizzle-orm';\nimport type { DbExecutor } from '../client.js';\nimport type { Connection, ConnectionsRepository } from '@headless-lms/core/integrations';\nimport { connections } from '../schema/integrations.js';\nimport type { Logger } from '@headless-lms/core/shared/ports';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\n\ntype Row = typeof connections.$inferSelect;\n\nfunction toConnection(row: Row): Connection {\n return {\n orgId: row.orgId,\n id: row.id,\n integrationId: row.integrationId,\n config: row.config,\n active: row.active,\n credentialRef: row.credentialRef,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nexport class DrizzleConnectionsRepository implements ConnectionsRepository {\n constructor(\n private readonly db: DbExecutor,\n private readonly logger: Logger = noopLogger,\n ) {}\n\n async insert(orgId: string, connection: Connection): Promise<Connection> {\n if (connection.orgId !== orgId) {\n throw new Error('connection org mismatch');\n }\n const [row] = await this.db\n .insert(connections)\n .values({\n orgId: connection.orgId,\n id: connection.id,\n integrationId: connection.integrationId,\n config: connection.config,\n active: connection.active,\n credentialRef: connection.credentialRef,\n createdAt: new Date(connection.createdAt),\n updatedAt: new Date(connection.updatedAt),\n })\n .returning();\n if (!row) {\n throw new Error('failed to insert connection');\n }\n return toConnection(row);\n }\n\n async findById(orgId: string, id: string): Promise<Connection | null> {\n const [row] = await this.db\n .select()\n .from(connections)\n .where(and(eq(connections.orgId, orgId), eq(connections.id, id)))\n .limit(1);\n return row ? toConnection(row) : null;\n }\n\n async findByIntegration(orgId: string, integrationId: string): Promise<Connection | null> {\n const [row] = await this.db\n .select()\n .from(connections)\n .where(and(eq(connections.orgId, orgId), eq(connections.integrationId, integrationId)))\n .limit(1);\n return row ? toConnection(row) : null;\n }\n\n async list(orgId: string): Promise<Connection[]> {\n const rows = await this.db\n .select()\n .from(connections)\n .where(eq(connections.orgId, orgId))\n .orderBy(asc(connections.integrationId));\n return rows.map(toConnection);\n }\n\n async update(\n orgId: string,\n id: string,\n patch: Partial<Pick<Connection, 'config' | 'active' | 'updatedAt'>>,\n ): Promise<Connection | null> {\n const [row] = await this.db\n .update(connections)\n .set({\n ...(patch.config !== undefined ? { config: patch.config } : {}),\n ...(patch.active !== undefined ? { active: patch.active } : {}),\n ...(patch.updatedAt !== undefined ? { updatedAt: new Date(patch.updatedAt) } : {}),\n })\n .where(and(eq(connections.orgId, orgId), eq(connections.id, id)))\n .returning();\n return row ? toConnection(row) : null;\n }\n\n async delete(orgId: string, id: string): Promise<boolean> {\n const deleted = await this.db\n .delete(connections)\n .where(and(eq(connections.orgId, orgId), eq(connections.id, id)))\n .returning({ id: connections.id });\n return deleted.length > 0;\n }\n}\n"],"mappings":";;;;AAUA,SAAS,aAAa,KAAsB;CAC1C,OAAO;EACL,OAAO,IAAI;EACX,IAAI,IAAI;EACR,eAAe,IAAI;EACnB,QAAQ,IAAI;EACZ,QAAQ,IAAI;EACZ,eAAe,IAAI;EACnB,WAAW,IAAI;EACf,WAAW,IAAI;CACjB;AACF;AAEA,IAAa,+BAAb,MAA2E;CAEtD;CACA;CAFnB,YACE,IACA,SAAkC,YAClC;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,OAAO,OAAe,YAA6C;EACvE,IAAI,WAAW,UAAU,OACvB,MAAM,IAAI,MAAM,yBAAyB;EAE3C,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,WAAW,CAAC,CACnB,OAAO;GACN,OAAO,WAAW;GAClB,IAAI,WAAW;GACf,eAAe,WAAW;GAC1B,QAAQ,WAAW;GACnB,QAAQ,WAAW;GACnB,eAAe,WAAW;GAC1B,WAAW,IAAI,KAAK,WAAW,SAAS;GACxC,WAAW,IAAI,KAAK,WAAW,SAAS;EAC1C,CAAC,CAAC,CACD,UAAU;EACb,IAAI,CAAC,KACH,MAAM,IAAI,MAAM,6BAA6B;EAE/C,OAAO,aAAa,GAAG;CACzB;CAEA,MAAM,SAAS,OAAe,IAAwC;EACpE,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,CAAC,CACR,KAAK,WAAW,CAAC,CACjB,MAAM,IAAI,GAAG,YAAY,OAAO,KAAK,GAAG,GAAG,YAAY,IAAI,EAAE,CAAC,CAAC,CAAC,CAChE,MAAM,CAAC;EACV,OAAO,MAAM,aAAa,GAAG,IAAI;CACnC;CAEA,MAAM,kBAAkB,OAAe,eAAmD;EACxF,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,CAAC,CACR,KAAK,WAAW,CAAC,CACjB,MAAM,IAAI,GAAG,YAAY,OAAO,KAAK,GAAG,GAAG,YAAY,eAAe,aAAa,CAAC,CAAC,CAAC,CACtF,MAAM,CAAC;EACV,OAAO,MAAM,aAAa,GAAG,IAAI;CACnC;CAEA,MAAM,KAAK,OAAsC;EAM/C,QAAO,MALY,KAAK,GACrB,OAAO,CAAC,CACR,KAAK,WAAW,CAAC,CACjB,MAAM,GAAG,YAAY,OAAO,KAAK,CAAC,CAAC,CACnC,QAAQ,IAAI,YAAY,aAAa,CAAC,EAAA,CAC7B,IAAI,YAAY;CAC9B;CAEA,MAAM,OACJ,OACA,IACA,OAC4B;EAC5B,MAAM,CAAC,OAAO,MAAM,KAAK,GACtB,OAAO,WAAW,CAAC,CACnB,IAAI;GACH,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQ,MAAM,OAAO,IAAI,CAAC;GAC7D,GAAI,MAAM,WAAW,KAAA,IAAY,EAAE,QAAQ,MAAM,OAAO,IAAI,CAAC;GAC7D,GAAI,MAAM,cAAc,KAAA,IAAY,EAAE,WAAW,IAAI,KAAK,MAAM,SAAS,EAAE,IAAI,CAAC;EAClF,CAAC,CAAC,CACD,MAAM,IAAI,GAAG,YAAY,OAAO,KAAK,GAAG,GAAG,YAAY,IAAI,EAAE,CAAC,CAAC,CAAC,CAChE,UAAU;EACb,OAAO,MAAM,aAAa,GAAG,IAAI;CACnC;CAEA,MAAM,OAAO,OAAe,IAA8B;EAKxD,QAAO,MAJe,KAAK,GACxB,OAAO,WAAW,CAAC,CACnB,MAAM,IAAI,GAAG,YAAY,OAAO,KAAK,GAAG,GAAG,YAAY,IAAI,EAAE,CAAC,CAAC,CAAC,CAChE,UAAU,EAAE,IAAI,YAAY,GAAG,CAAC,EAAA,CACpB,SAAS;CAC1B;AACF"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { Logger } from "@headless-lms/core/shared/ports";
|
|
3
|
+
import { ContentRef, LearnEntitlementReader } from "@headless-lms/core/reporting/learn";
|
|
4
|
+
//#region src/repositories/learn.d.ts
|
|
5
|
+
declare class DrizzleLearnRepository implements LearnEntitlementReader {
|
|
6
|
+
private readonly db;
|
|
7
|
+
private readonly logger;
|
|
8
|
+
constructor(db: NodePgDatabase, logger?: Logger);
|
|
9
|
+
private baseFilters;
|
|
10
|
+
activeRefs(orgId: string, orgUserId: string): Promise<ContentRef[]>;
|
|
11
|
+
activeRef(orgId: string, orgUserId: string, courseId: string): Promise<ContentRef | null>;
|
|
12
|
+
private downloadFilters;
|
|
13
|
+
activeDownloadRefs(orgId: string, orgUserId: string): Promise<ContentRef[]>;
|
|
14
|
+
activeDownloadRef(orgId: string, orgUserId: string, downloadId: string): Promise<ContentRef | null>;
|
|
15
|
+
downloadHasAsset(orgId: string, downloadId: string, assetId: string): Promise<boolean>;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { DrizzleLearnRepository };
|
|
19
|
+
//# sourceMappingURL=learn.d.ts.map
|