refactor(peer-support): delete provably dead code (Phase 1)
The Skills Exchange + Peer Support feature was replaced by Community
Connections on 2026-04-05, but several files and code paths were left
in place as backward-compat. None are reachable from the live UI:
- usePeerSupport.js composable: not imported anywhere
- PeerSupportBadge.vue: not imported anywhere
- peer-support.vue: stub redirect with no incoming links
- /api/peer-support.get.js: only consumed by usePeerSupport
- /api/members/me/peer-support.patch.js: same
- profile.patch.js offering/lookingFor write branches: profile form
no longer sends these fields (only writes communityConnections.*)
- PEER_SUPPORT_ENABLED/DISABLED activity types and renderers: only
written by the deleted peer-support.patch endpoint. The activityText
formatter has a fallback for unknown types so existing records
still display ("peer support enabled" with a generic icon).
Tests updated to drop peerSupportUpdateSchema coverage and the
offering/lookingFor passthrough assertion.
schemas.js cleanup deferred — concurrent communityConnections →
communityEcology rename is in flight in the working tree.
This commit is contained in:
parent
130e5bfa9f
commit
9577929e0d
11 changed files with 0 additions and 366 deletions
|
|
@ -1,101 +0,0 @@
|
||||||
<template>
|
|
||||||
<!-- Corner Sticker Badge -->
|
|
||||||
<div
|
|
||||||
v-if="type === 'sticker'"
|
|
||||||
class="absolute top-2 right-2 z-10"
|
|
||||||
:title="title"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative transform rotate-3 hover:rotate-0 transition-transform"
|
|
||||||
style="width: 60px; height: 66px"
|
|
||||||
>
|
|
||||||
<!-- Shield background -->
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 1000 1000"
|
|
||||||
class="absolute inset-0 w-full h-full drop-shadow-lg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M500 70 150 175.3v217.1C150 785 500 930 500 930s350-145 350-537.6V175.2L500 70Z"
|
|
||||||
class="fill-candlelight-500"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Content on top of shield -->
|
|
||||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
|
||||||
<Icon
|
|
||||||
name="heroicons:chat-bubble-left-right-solid"
|
|
||||||
class="w-6 h-6 text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sparkle effect -->
|
|
||||||
<div
|
|
||||||
class="absolute top-0 right-1 w-2 h-2 bg-candlelight-300 rounded-full animate-pulse"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inline Badge -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
:class="[
|
|
||||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium transition-all',
|
|
||||||
variant === 'default' &&
|
|
||||||
'bg-candlelight-900/20 text-candlelight-400 border-candlelight-500/40 hover:bg-candlelight-900/30',
|
|
||||||
variant === 'subtle' &&
|
|
||||||
'bg-candlelight-900/10 text-candlelight-500 border-candlelight-500/20',
|
|
||||||
variant === 'solid' &&
|
|
||||||
'bg-candlelight-500 text-white border-candlelight-600 hover:bg-candlelight-600',
|
|
||||||
]"
|
|
||||||
:title="title"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name="heroicons:chat-bubble-left-right"
|
|
||||||
:class="[
|
|
||||||
'w-3.5 h-3.5',
|
|
||||||
variant === 'default' && 'text-candlelight-400',
|
|
||||||
variant === 'subtle' && 'text-candlelight-500',
|
|
||||||
variant === 'solid' && 'text-white',
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<span>{{ label }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
/**
|
|
||||||
* Badge type - inline or corner sticker
|
|
||||||
* @values inline, sticker
|
|
||||||
*/
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
default: "inline",
|
|
||||||
validator: (value) => ["inline", "sticker"].includes(value),
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Display variant of the badge (for inline type)
|
|
||||||
* @values default, subtle, solid
|
|
||||||
*/
|
|
||||||
variant: {
|
|
||||||
type: String,
|
|
||||||
default: "default",
|
|
||||||
validator: (value) => ["default", "subtle", "solid"].includes(value),
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Custom label text (defaults to "Offering Peer Support")
|
|
||||||
*/
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: "Offering Peer Support",
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Tooltip/title text
|
|
||||||
*/
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: "This member offers 1:1 peer support sessions",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
export const usePeerSupport = () => {
|
|
||||||
const updateSettings = async (settings) => {
|
|
||||||
return await $fetch('/api/members/me/peer-support', {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: settings
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSupporters = async (topic) => {
|
|
||||||
return await $fetch('/api/peer-support', {
|
|
||||||
query: topic ? { topic } : {}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return { updateSettings, getSupporters };
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<template>
|
|
||||||
<div></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// Redirect to connections page
|
|
||||||
definePageMeta({
|
|
||||||
middleware: defineNuxtRouteMiddleware(() => {
|
|
||||||
return navigateTo("/connections");
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
@ -23,16 +23,6 @@ const formatters = {
|
||||||
link: null,
|
link: null,
|
||||||
linkText: null
|
linkText: null
|
||||||
}),
|
}),
|
||||||
peer_support_enabled: (m) => ({
|
|
||||||
text: m.topics?.length
|
|
||||||
? `Enabled peer support (${m.topics.join(', ')})`
|
|
||||||
: 'Enabled peer support',
|
|
||||||
icon: 'i-lucide-users'
|
|
||||||
}),
|
|
||||||
peer_support_disabled: () => ({
|
|
||||||
text: 'Disabled peer support',
|
|
||||||
icon: 'i-lucide-users'
|
|
||||||
}),
|
|
||||||
circle_changed: (m) => ({
|
circle_changed: (m) => ({
|
||||||
text: `Changed circle from ${circleLabel(m.from)} to ${circleLabel(m.to)}`,
|
text: `Changed circle from ${circleLabel(m.from)} to ${circleLabel(m.to)}`,
|
||||||
icon: 'i-lucide-refresh-cw'
|
icon: 'i-lucide-refresh-cw'
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import Member from '../../../models/member.js'
|
|
||||||
import { connectDB } from '../../../utils/mongoose.js'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
await connectDB()
|
|
||||||
const member = await requireAuth(event)
|
|
||||||
|
|
||||||
const body = await validateBody(event, peerSupportUpdateSchema)
|
|
||||||
|
|
||||||
// Build update object for peer support settings
|
|
||||||
const updateData = {
|
|
||||||
'peerSupport.enabled': body.enabled || false,
|
|
||||||
'peerSupport.skillTopics': body.skillTopics || [],
|
|
||||||
'peerSupport.supportTopics': body.supportTopics || [],
|
|
||||||
'peerSupport.availability': body.availability || '',
|
|
||||||
'peerSupport.personalMessage': body.personalMessage || '',
|
|
||||||
'peerSupport.slackUsername': body.slackUsername || '',
|
|
||||||
}
|
|
||||||
|
|
||||||
// If Slack username provided and peer support enabled, try to fetch Slack user ID
|
|
||||||
if (body.enabled && body.slackUsername) {
|
|
||||||
try {
|
|
||||||
console.log(
|
|
||||||
`[Peer Support] Attempting to fetch Slack user ID for: ${body.slackUsername}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const { getSlackService } = await import('../../../utils/slack.ts')
|
|
||||||
const slackService = getSlackService()
|
|
||||||
|
|
||||||
if (slackService) {
|
|
||||||
console.log('[Peer Support] Slack service initialized, looking up user...')
|
|
||||||
const slackUserId = await slackService.findUserIdByUsername(body.slackUsername)
|
|
||||||
|
|
||||||
if (slackUserId) {
|
|
||||||
updateData['slackUserId'] = slackUserId
|
|
||||||
console.log(
|
|
||||||
`[Peer Support] ✓ Found Slack user ID for ${body.slackUsername}: ${slackUserId}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('[Peer Support] Opening DM channel...')
|
|
||||||
const dmChannelId = await slackService.openDMChannel(slackUserId)
|
|
||||||
|
|
||||||
if (dmChannelId) {
|
|
||||||
updateData['peerSupport.slackDMChannelId'] = dmChannelId
|
|
||||||
console.log(`[Peer Support] ✓ Got DM channel ID: ${dmChannelId}`)
|
|
||||||
} else {
|
|
||||||
console.warn('[Peer Support] Could not get DM channel ID')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`[Peer Support] Could not find Slack user ID for username: ${body.slackUsername}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('[Peer Support] Slack service not configured, skipping user ID lookup')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Peer Support] Error fetching Slack user ID:', error.message)
|
|
||||||
console.error('[Peer Support] Stack trace:', error.stack)
|
|
||||||
// Continue anyway - we'll still save the username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await Member.findByIdAndUpdate(
|
|
||||||
member._id,
|
|
||||||
{ $set: updateData },
|
|
||||||
{ new: true, runValidators: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 404,
|
|
||||||
statusMessage: 'Member not found',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.enabled) {
|
|
||||||
logActivity(member._id, 'peer_support_enabled', {
|
|
||||||
topics: [...(body.skillTopics || []), ...(body.supportTopics || [])]
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
logActivity(member._id, 'peer_support_disabled', {})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
peerSupport: updated.peerSupport,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Peer support update error:', error)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'Failed to update peer support settings',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -31,8 +31,6 @@ export default defineEventHandler(async (event) => {
|
||||||
"bioPrivacy",
|
"bioPrivacy",
|
||||||
"locationPrivacy",
|
"locationPrivacy",
|
||||||
"socialLinksPrivacy",
|
"socialLinksPrivacy",
|
||||||
"offeringPrivacy",
|
|
||||||
"lookingForPrivacy",
|
|
||||||
"craftTagsPrivacy",
|
"craftTagsPrivacy",
|
||||||
"communityConnectionsPrivacy",
|
"communityConnectionsPrivacy",
|
||||||
];
|
];
|
||||||
|
|
@ -51,20 +49,6 @@ export default defineEventHandler(async (event) => {
|
||||||
updateData.craftTags = body.craftTags;
|
updateData.craftTags = body.craftTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle offering and lookingFor separately (nested objects)
|
|
||||||
if (body.offering !== undefined) {
|
|
||||||
updateData.offering = {
|
|
||||||
text: body.offering.text || "",
|
|
||||||
tags: body.offering.tags || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (body.lookingFor !== undefined) {
|
|
||||||
updateData.lookingFor = {
|
|
||||||
text: body.lookingFor.text || "",
|
|
||||||
tags: body.lookingFor.tags || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle privacy settings
|
// Handle privacy settings
|
||||||
privacyFields.forEach((privacyField) => {
|
privacyFields.forEach((privacyField) => {
|
||||||
if (body[privacyField] !== undefined) {
|
if (body[privacyField] !== undefined) {
|
||||||
|
|
@ -107,8 +91,6 @@ export default defineEventHandler(async (event) => {
|
||||||
bio: member.bio,
|
bio: member.bio,
|
||||||
location: member.location,
|
location: member.location,
|
||||||
socialLinks: member.socialLinks,
|
socialLinks: member.socialLinks,
|
||||||
offering: member.offering,
|
|
||||||
lookingFor: member.lookingFor,
|
|
||||||
craftTags: member.craftTags,
|
craftTags: member.craftTags,
|
||||||
showInDirectory: member.showInDirectory,
|
showInDirectory: member.showInDirectory,
|
||||||
notifications: member.notifications,
|
notifications: member.notifications,
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
import Member from "../models/member.js";
|
|
||||||
import { connectDB } from "../utils/mongoose.js";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
await connectDB();
|
|
||||||
|
|
||||||
// Check if user is authenticated (optional for this endpoint)
|
|
||||||
const token = getCookie(event, "auth-token");
|
|
||||||
let isAuthenticated = false;
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
jwt.verify(token, useRuntimeConfig().jwtSecret);
|
|
||||||
isAuthenticated = true;
|
|
||||||
} catch (err) {
|
|
||||||
isAuthenticated = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = getQuery(event);
|
|
||||||
const topic = query.topic;
|
|
||||||
|
|
||||||
// Build query for peer supporters
|
|
||||||
const dbQuery = {
|
|
||||||
"peerSupport.enabled": true,
|
|
||||||
status: "active",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter by topic if specified
|
|
||||||
if (topic) {
|
|
||||||
dbQuery["peerSupport.topics"] = topic;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const supporters = await Member.find(dbQuery)
|
|
||||||
.select(
|
|
||||||
"name avatar circle peerSupport slackUserId createdAt"
|
|
||||||
)
|
|
||||||
.sort({ createdAt: -1 })
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
// Get unique topics for filter options
|
|
||||||
const allTopics = supporters
|
|
||||||
.flatMap((supporter) => supporter.peerSupport?.topics || [])
|
|
||||||
.filter((topic, index, self) => self.indexOf(topic) === index)
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
return {
|
|
||||||
supporters,
|
|
||||||
totalCount: supporters.length,
|
|
||||||
filters: {
|
|
||||||
availableTopics: allTopics,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Peer support fetch error:", error);
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: "Failed to fetch peer supporters",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -5,8 +5,6 @@ const ACTIVITY_TYPES = [
|
||||||
'event_registered',
|
'event_registered',
|
||||||
'event_cancelled',
|
'event_cancelled',
|
||||||
'event_waitlisted',
|
'event_waitlisted',
|
||||||
'peer_support_enabled',
|
|
||||||
'peer_support_disabled',
|
|
||||||
'circle_changed',
|
'circle_changed',
|
||||||
'contribution_changed',
|
'contribution_changed',
|
||||||
'email_changed',
|
'email_changed',
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ export const ACTIVITY_TYPES = {
|
||||||
EVENT_REGISTERED: 'event_registered',
|
EVENT_REGISTERED: 'event_registered',
|
||||||
EVENT_CANCELLED: 'event_cancelled',
|
EVENT_CANCELLED: 'event_cancelled',
|
||||||
EVENT_WAITLISTED: 'event_waitlisted',
|
EVENT_WAITLISTED: 'event_waitlisted',
|
||||||
PEER_SUPPORT_ENABLED: 'peer_support_enabled',
|
|
||||||
PEER_SUPPORT_DISABLED: 'peer_support_disabled',
|
|
||||||
CIRCLE_CHANGED: 'circle_changed',
|
CIRCLE_CHANGED: 'circle_changed',
|
||||||
CONTRIBUTION_CHANGED: 'contribution_changed',
|
CONTRIBUTION_CHANGED: 'contribution_changed',
|
||||||
EMAIL_CHANGED: 'email_changed',
|
EMAIL_CHANGED: 'email_changed',
|
||||||
|
|
@ -29,8 +27,6 @@ export const ACTIVITY_TYPE_DEFAULTS = {
|
||||||
event_registered: 'public',
|
event_registered: 'public',
|
||||||
event_cancelled: 'member',
|
event_cancelled: 'member',
|
||||||
event_waitlisted: 'member',
|
event_waitlisted: 'member',
|
||||||
peer_support_enabled: 'public',
|
|
||||||
peer_support_disabled: 'member',
|
|
||||||
circle_changed: 'member',
|
circle_changed: 'member',
|
||||||
contribution_changed: 'member',
|
contribution_changed: 'member',
|
||||||
email_changed: 'member',
|
email_changed: 'member',
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,6 @@ describe('members profile PATCH endpoint', () => {
|
||||||
bio: 'Updated bio',
|
bio: 'Updated bio',
|
||||||
location: 'NYC',
|
location: 'NYC',
|
||||||
socialLinks: { twitter: '@test' },
|
socialLinks: { twitter: '@test' },
|
||||||
offering: { text: 'help', tags: ['code'] },
|
|
||||||
lookingFor: { text: 'feedback', tags: ['design'] },
|
|
||||||
showInDirectory: true
|
showInDirectory: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,21 +135,5 @@ describe('members profile PATCH endpoint', () => {
|
||||||
expect(setData).toHaveProperty('socialLinks')
|
expect(setData).toHaveProperty('socialLinks')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes offering and lookingFor nested objects through', async () => {
|
|
||||||
const event = createMockEvent({
|
|
||||||
method: 'PATCH',
|
|
||||||
path: '/api/members/profile',
|
|
||||||
body: {
|
|
||||||
offering: { text: 'mentoring', tags: ['code', 'design'] },
|
|
||||||
lookingFor: { text: 'feedback', tags: ['art'] }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await profilePatchHandler(event)
|
|
||||||
|
|
||||||
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
|
|
||||||
expect(setData.offering).toEqual({ text: 'mentoring', tags: ['code', 'design'] })
|
|
||||||
expect(setData.lookingFor).toEqual({ text: 'feedback', tags: ['art'] })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
guestRegisterSchema,
|
guestRegisterSchema,
|
||||||
eventPaymentSchema,
|
eventPaymentSchema,
|
||||||
updateContributionSchema,
|
updateContributionSchema,
|
||||||
peerSupportUpdateSchema,
|
|
||||||
seriesTicketPurchaseSchema,
|
seriesTicketPurchaseSchema,
|
||||||
seriesTicketEligibilitySchema,
|
seriesTicketEligibilitySchema,
|
||||||
adminSeriesCreateSchema,
|
adminSeriesCreateSchema,
|
||||||
|
|
@ -305,29 +304,6 @@ describe('updateContributionSchema', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('peerSupportUpdateSchema', () => {
|
|
||||||
it('accepts valid peer support data', () => {
|
|
||||||
const result = peerSupportUpdateSchema.safeParse({
|
|
||||||
enabled: true,
|
|
||||||
skillTopics: ['game design', 'business'],
|
|
||||||
slackUsername: 'jane'
|
|
||||||
})
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts empty object', () => {
|
|
||||||
const result = peerSupportUpdateSchema.safeParse({})
|
|
||||||
expect(result.success).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects non-array skillTopics', () => {
|
|
||||||
const result = peerSupportUpdateSchema.safeParse({
|
|
||||||
skillTopics: 'not-an-array'
|
|
||||||
})
|
|
||||||
expect(result.success).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Series schemas ---
|
// --- Series schemas ---
|
||||||
|
|
||||||
describe('seriesTicketPurchaseSchema', () => {
|
describe('seriesTicketPurchaseSchema', () => {
|
||||||
|
|
@ -505,7 +481,6 @@ describe('validateBody migration coverage', () => {
|
||||||
'events/[id]/guest-register.post.js',
|
'events/[id]/guest-register.post.js',
|
||||||
'events/[id]/payment.post.js',
|
'events/[id]/payment.post.js',
|
||||||
'members/update-contribution.post.js',
|
'members/update-contribution.post.js',
|
||||||
'members/me/peer-support.patch.js',
|
|
||||||
'series/[id]/tickets/purchase.post.js',
|
'series/[id]/tickets/purchase.post.js',
|
||||||
'series/[id]/tickets/check-eligibility.post.js',
|
'series/[id]/tickets/check-eligibility.post.js',
|
||||||
'admin/series.post.js',
|
'admin/series.post.js',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue