Only soft-delete messages

This commit is contained in:
Mike Schwörer 2022-12-14 12:29:55 +01:00
parent 98b1e8bd80
commit 66ecad27a7
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
10 changed files with 238 additions and 46 deletions

View File

@ -1237,7 +1237,7 @@ func (h APIHandler) GetMessage(g *gin.Context) ginresp.HTTPResponse {
return *permResp
}
msg, err := h.database.GetMessage(ctx, u.MessageID)
msg, err := h.database.GetMessage(ctx, u.MessageID, false)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.MESSAGE_NOT_FOUND, "message not found", err)
}
@ -1307,7 +1307,7 @@ func (h APIHandler) DeleteMessage(g *gin.Context) ginresp.HTTPResponse {
return *permResp
}
msg, err := h.database.GetMessage(ctx, u.MessageID)
msg, err := h.database.GetMessage(ctx, u.MessageID, false)
if err == sql.ErrNoRows {
return ginresp.APIError(g, 404, apierr.MESSAGE_NOT_FOUND, "message not found", err)
}

View File

@ -533,7 +533,7 @@ func (h CompatHandler) Expand(g *gin.Context) ginresp.HTTPResponse {
return ginresp.CompatAPIError(204, "Authentification failed")
}
msg, err := h.database.GetMessage(ctx, models.SCNMessageID(*data.MessageID))
msg, err := h.database.GetMessage(ctx, models.SCNMessageID(*data.MessageID), false)
if err == sql.ErrNoRows {
return ginresp.CompatAPIError(301, "Message not found")
}

View File

@ -196,6 +196,7 @@ func (h MessageHandler) sendMessageInternal(g *gin.Context, ctx *logic.AppContex
return ginresp.SendAPIError(g, 500, apierr.DATABASE_ERROR, hl.NONE, "Failed to query existing message", err)
}
if msg != nil {
//the found message can be deleted (!), but we still return NO_ERROR here...
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{
Success: true,
ErrorID: apierr.NO_ERROR,

View File

@ -30,13 +30,20 @@ func (db *Database) GetMessageByUserMessageID(ctx TxContext, usrMsgId string) (*
return &msg, nil
}
func (db *Database) GetMessage(ctx TxContext, scnMessageID models.SCNMessageID) (models.Message, error) {
func (db *Database) GetMessage(ctx TxContext, scnMessageID models.SCNMessageID, allowDeleted bool) (models.Message, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Message{}, err
}
rows, err := tx.Query(ctx, "SELECT * FROM messages WHERE scn_message_id = :mid LIMIT 1", sq.PP{"mid": scnMessageID})
var sqlcmd string
if allowDeleted {
sqlcmd = "SELECT * FROM messages WHERE scn_message_id = :mid LIMIT 1"
} else {
sqlcmd = "SELECT * FROM messages WHERE scn_message_id = :mid AND deleted=0 LIMIT 1"
}
rows, err := tx.Query(ctx, sqlcmd, sq.PP{"mid": scnMessageID})
if err != nil {
return models.Message{}, err
}
@ -103,7 +110,7 @@ func (db *Database) DeleteMessage(ctx TxContext, scnMessageID models.SCNMessageI
return err
}
_, err = tx.Exec(ctx, "DELETE FROM messages WHERE scn_message_id = :mid", sq.PP{"mid": scnMessageID})
_, err = tx.Exec(ctx, "UPDATE messages SET deleted=1 WHERE scn_message_id = :mid AND deleted=0", sq.PP{"mid": scnMessageID})
if err != nil {
return err
}

View File

@ -90,7 +90,9 @@ CREATE TABLE messages
title TEXT NOT NULL,
content TEXT NULL,
priority INTEGER CHECK(priority IN (0, 1, 2)) NOT NULL,
usr_message_id TEXT NULL
usr_message_id TEXT NULL,
deleted INTEGER CHECK(deleted IN (0, 1)) NOT NULL DEFAULT '0'
) STRICT;
CREATE INDEX "idx_messages_owner_channel" ON messages (owner_user_id, channel_name COLLATE BINARY);
CREATE INDEX "idx_messages_owner_channel_nc" ON messages (owner_user_id, channel_name COLLATE NOCASE);
@ -102,6 +104,7 @@ CREATE INDEX "idx_messages_sendername" ON messages (sender_name COL
CREATE INDEX "idx_messages_sendername_nc" ON messages (sender_name COLLATE NOCASE);
CREATE INDEX "idx_messages_title" ON messages (title COLLATE BINARY);
CREATE INDEX "idx_messages_title_nc" ON messages (title COLLATE NOCASE);
CREATE INDEX "idx_messages_deleted" ON messages (deleted);
CREATE VIRTUAL TABLE messages_fts USING fts5

View File

@ -93,36 +93,47 @@ func (j *DeliveryRetryJob) redeliver(ctx *logic.SimpleContext, delivery models.D
return
}
msg, err := j.app.Database.GetMessage(ctx, delivery.SCNMessageID)
msg, err := j.app.Database.GetMessage(ctx, delivery.SCNMessageID, true)
if err != nil {
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Msg("Failed to get message")
ctx.RollbackTransaction()
return
}
fcmDelivID, err := j.app.DeliverMessage(ctx, client, msg)
if err == nil {
err = j.app.Database.SetDeliverySuccess(ctx, delivery, *fcmDelivID)
if err != nil {
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
} else if delivery.RetryCount+1 > delivery.MaxRetryCount() {
if msg.Deleted {
err = j.app.Database.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
log.Warn().Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Delivery failed after <max> retries (set to FAILURE)")
} else {
err = j.app.Database.SetDeliveryRetry(ctx, delivery)
if err != nil {
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
fcmDelivID, err := j.app.DeliverMessage(ctx, client, msg)
if err == nil {
err = j.app.Database.SetDeliverySuccess(ctx, delivery, *fcmDelivID)
if err != nil {
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
} else if delivery.RetryCount+1 > delivery.MaxRetryCount() {
err = j.app.Database.SetDeliveryFailed(ctx, delivery)
if err != nil {
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
log.Warn().Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Delivery failed after <max> retries (set to FAILURE)")
} else {
err = j.app.Database.SetDeliveryRetry(ctx, delivery)
if err != nil {
log.Err(err).Int64("SCNMessageID", delivery.SCNMessageID.IntID()).Int64("DeliveryID", delivery.DeliveryID.IntID()).Msg("Failed to update delivery")
ctx.RollbackTransaction()
return
}
}
}
err = ctx.CommitTransaction()

View File

@ -26,6 +26,7 @@ type Message struct {
Content *string
Priority int
UserMessageID *string
Deleted bool
}
func (m Message) FullJSON() MessageJSON {
@ -122,6 +123,7 @@ type MessageDB struct {
Content *string `db:"content"`
Priority int `db:"priority"`
UserMessageID *string `db:"usr_message_id"`
Deleted int `db:"deleted"`
}
func (m MessageDB) Model() Message {
@ -139,6 +141,7 @@ func (m MessageDB) Model() Message {
Content: m.Content,
Priority: m.Priority,
UserMessageID: m.UserMessageID,
Deleted: m.Deleted != 0,
}
}

View File

@ -37,6 +37,8 @@ type MessageFilter struct {
TitleCI *string // case-insensitive
Priority *[]int
UserMessageID *[]string
OnlyDeleted bool
IncludeDeleted bool
}
func (f MessageFilter) SQL() (string, string, sq.PP, error) {
@ -53,6 +55,14 @@ func (f MessageFilter) SQL() (string, string, sq.PP, error) {
params := sq.PP{}
if f.OnlyDeleted {
sqlClauses = append(sqlClauses, "(deleted=1)")
} else if f.IncludeDeleted {
// nothing, return all
} else {
sqlClauses = append(sqlClauses, "(deleted=0)") // default
}
if f.ConfirmedSubscriptionBy != nil {
sqlClauses = append(sqlClauses, "(subs.subscriber_user_id = :sub_uid AND subs.confirmed = 1)")
params["sub_uid"] = *f.ConfirmedSubscriptionBy

View File

@ -1,12 +1,15 @@
package test
import (
"blackforestbytes.com/simplecloudnotifier/api/apierr"
tt "blackforestbytes.com/simplecloudnotifier/test/util"
"fmt"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"net/url"
"testing"
"time"
)
func TestSearchMessageFTSSimple(t *testing.T) {
@ -31,4 +34,154 @@ func TestSearchMessageFTSMulti(t *testing.T) {
//TODO search for messages by FTS
}
//TODO test missing message-xx methods
//TODO more search/list/filter message tests
//TODO list messages by chan_key
//TODO list messages from channel that you cannot see
func TestDeleteMessage(t *testing.T) {
ws, stop := tt.StartSimpleWebserver(t)
defer stop()
baseUrl := "http://127.0.0.1:" + ws.Port
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
"agent_model": "DUMMY_PHONE",
"agent_version": "4X",
"client_type": "ANDROID",
"fcm_token": "DUMMY_FCM",
})
uid := int(r0["user_id"].(float64))
sendtok := r0["send_key"].(string)
admintok := r0["admin_key"].(string)
msg1 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
"user_key": sendtok,
"user_id": uid,
"title": "Message_1",
})
tt.RequestAuthGet[tt.Void](t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]))
tt.RequestAuthDelete[tt.Void](t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]), gin.H{})
tt.RequestAuthGetShouldFail(t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]), 404, apierr.MESSAGE_NOT_FOUND)
}
func TestDeleteMessageAndResendUsrMsgId(t *testing.T) {
ws, stop := tt.StartSimpleWebserver(t)
defer stop()
baseUrl := "http://127.0.0.1:" + ws.Port
r0 := tt.RequestPost[gin.H](t, baseUrl, "/api/users", gin.H{
"agent_model": "DUMMY_PHONE",
"agent_version": "4X",
"client_type": "ANDROID",
"fcm_token": "DUMMY_FCM",
})
uid := int(r0["user_id"].(float64))
sendtok := r0["send_key"].(string)
admintok := r0["admin_key"].(string)
msg1 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
"user_key": sendtok,
"user_id": uid,
"title": "Message_1",
"msg_id": "bef8dd3d-078e-4f89-abf4-5258ad22a2e4",
})
tt.AssertEqual(t, "suppress_send", false, msg1["suppress_send"])
tt.RequestAuthGet[tt.Void](t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]))
msg2 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
"user_key": sendtok,
"user_id": uid,
"title": "Message_1",
"msg_id": "bef8dd3d-078e-4f89-abf4-5258ad22a2e4",
})
tt.AssertEqual(t, "suppress_send", true, msg2["suppress_send"])
tt.RequestAuthDelete[tt.Void](t, admintok, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msg1["scn_msg_id"]), gin.H{})
// even though message is deleted, we still get a `suppress_send` on send_message
msg3 := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
"user_key": sendtok,
"user_id": uid,
"title": "Message_1",
"msg_id": "bef8dd3d-078e-4f89-abf4-5258ad22a2e4",
})
tt.AssertEqual(t, "suppress_send", true, msg3["suppress_send"])
}
func TestGetMessageSimple(t *testing.T) {
ws, stop := tt.StartSimpleWebserver(t)
defer stop()
baseUrl := "http://127.0.0.1:" + ws.Port
data := tt.InitDefaultData(t, ws)
msgOut := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
"user_key": data.User[0].SendKey,
"user_id": data.User[0].UID,
"title": "Message_1",
})
msgIn := tt.RequestAuthGet[gin.H](t, data.User[0].AdminKey, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msgOut["scn_msg_id"]))
tt.AssertEqual(t, "msg.title", "Message_1", msgIn["title"])
}
func TestGetMessageNotFound(t *testing.T) {
ws, stop := tt.StartSimpleWebserver(t)
defer stop()
baseUrl := "http://127.0.0.1:" + ws.Port
data := tt.InitDefaultData(t, ws)
tt.RequestAuthGetShouldFail(t, data.User[0].AdminKey, baseUrl, "/api/messages/8963586", 404, apierr.MESSAGE_NOT_FOUND)
}
func TestGetMessageFull(t *testing.T) {
ws, stop := tt.StartSimpleWebserver(t)
defer stop()
baseUrl := "http://127.0.0.1:" + ws.Port
data := tt.InitDefaultData(t, ws)
ts := time.Now().Unix() - 735
content := tt.Lipsum0(2)
msgOut := tt.RequestPost[gin.H](t, baseUrl, "/", gin.H{
"user_key": data.User[0].SendKey,
"user_id": data.User[0].UID,
"title": "Message_1",
"content": content,
"channel": "demo-channel-007",
"msg_id": "580b5055-a9b5-4cee-b53c-28cf304d25b0",
"priority": 0,
"sender_name": "unit-test-[TestGetMessageFull]",
"timestamp": ts,
})
msgIn := tt.RequestAuthGet[gin.H](t, data.User[0].AdminKey, baseUrl, "/api/messages/"+fmt.Sprintf("%v", msgOut["scn_msg_id"]))
tt.AssertEqual(t, "msg.title", "Message_1", msgIn["title"])
tt.AssertEqual(t, "msg.content", content, msgIn["content"])
tt.AssertEqual(t, "msg.channel", "demo-channel-007", msgIn["channel_name"])
tt.AssertEqual(t, "msg.msg_id", "580b5055-a9b5-4cee-b53c-28cf304d25b0", msgIn["usr_message_id"])
tt.AssertStrRepEqual(t, "msg.priority", 0, msgIn["priority"])
tt.AssertEqual(t, "msg.sender_name", "unit-test-[TestGetMessageFull]", msgIn["sender_name"])
tt.AssertEqual(t, "msg.timestamp", time.Unix(ts, 0).In(timeext.TimezoneBerlin).Format(time.RFC3339Nano), msgIn["timestamp"])
}

View File

@ -112,7 +112,7 @@ var messageExamples = []msgex{
{0, "", "", P0, SKEY, "Congratulations", "You have been selected as Employee of the Month. Please come to the front desk to pick up your prize", 0},
{0, "", "", PX, AKEY, "Attention", "The water cooler is empty. Could someone please refill it?", timeext.FromHours(-11.29)},
{0, "Chatting Chamber", "Mobile Mate", P2, SKEY, "Important", "All employees are required to complete a safety training course by the end of the month", 0},
{0, "", "", P1, AKEY, "FAQ Update", lipsum(10001, 1), 0},
{0, "", "", P1, AKEY, "FAQ Update", Lipsum(10001, 1), 0},
{0, "", "", PX, AKEY, "Notice", "There will be a fire drill at 10:00am tomorrow. Please follow the instructions of the fire marshal", 0},
{0, "", "Cellular Confidant", P2, SKEY, "Invitation", "You are invited to a celebration in honor of our 10-year anniversary. The party will be held on Friday at 7:00pm", 0},
{0, "", "", P0, SKEY, "Deadline reminder", "Please remember to submit your project proposal by the end of the day \U0001f638", 0},
@ -156,9 +156,9 @@ var messageExamples = []msgex{
{3, "", "", PX, AKEY, "Payment confirmation", "Your payment of $100 has been successfully processed. Thank you for your business.", 0},
{3, "", "", P2, SKEY, "Task completed", "Your task \"Update website content\" has been completed and is ready for review.", 0},
{3, "Innovations", "", PX, AKEY, "Invitation to join a group", "You have been invited to join the \"Marketing Team\" group on our collaboration platform.", 0},
{3, "", "", P2, SKEY, "Password reset", lipsum(10002, 1), 0},
{3, "", "", P2, SKEY, "Low battery alert", lipsum(10003, 2), 0},
{3, "Innovations", "", P2, SKEY, "System update available", lipsum(10004, 5), 0},
{3, "", "", P2, SKEY, "Password reset", Lipsum(10002, 1), 0},
{3, "", "", P2, SKEY, "Low battery alert", Lipsum(10003, 2), 0},
{3, "Innovations", "", P2, SKEY, "System update available", Lipsum(10004, 5), 0},
{3, "", "", P2, SKEY, "Appointment confirmation", "Your appointment for a physical exam on Monday, March 15th at 10 AM has been confirmed.", 0},
{3, "\U0001f5ff", "", P2, SKEY, "Order shipped", "Your order #123456 has been shipped and is on its way to your address.", 0},
{3, "", "", P2, SKEY, "Order cancelled", "Your order #123456 has been cancelled. We apologize for any inconvenience this may have caused.", 0},
@ -166,7 +166,7 @@ var messageExamples = []msgex{
{3, "Reminders", "", PX, AKEY, "Account verification", "", timeext.FromHours(1.15)},
{3, "Reminders", "", PX, AKEY, "Overdue payment", "", 0},
{3, "Reminders", "", P2, SKEY, "Security alert", "We have detected suspicious activity on your account. Please take the necessary steps to secure your account.", timeext.FromHours(0.80)},
{3, "Reminders", "", PX, AKEY, "Product back in stock", lipsum(10001, 6), 0},
{3, "Reminders", "", PX, AKEY, "Product back in stock", Lipsum(10001, 6), 0},
{3, "", "", PX, AKEY, "Connection lost", "Your device has lost its connection to the internet. Please check your network settings and try again.", 0},
{3, "", "", P2, SKEY, "Subscription renewal", "Your subscription is set to renew in one week. Please update your payment information to avoid any interruption in service.", 0},
{3, "", "", PX, AKEY, "Work order assigned", "You have been assigned a new work order #123456. Please review the details and complete the task as soon as possible.", 0},
@ -202,22 +202,22 @@ var messageExamples = []msgex{
{6, "", "server1", P2, SKEY, "Server performance improvement", "Thanks to recent upgrades, the server is now performing better than ever", 0},
{6, "", "server1", PX, AKEY, "Server security update", "The server has been updated with the latest security patches and enhancements", 0},
{6, "", "server1", P1, AKEY, "Server downtime schedule change", "The server downtime schedule has been changed to every other Friday at 8am EST", 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", lipsum(20001, 1), 0},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", lipsum(20002, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", lipsum(20003, 1), 0},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", lipsum(20004, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", lipsum(20005, 1), 0},
{6, "Lipsum", "", P1, AKEY, "Lorem Ipsum", lipsum(20006, 1), 0},
{6, "Lipsum", "", P1, AKEY, "Lorem Ipsum", lipsum(20007, 1), timeext.FromHours(-3.39)},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", lipsum(20008, 1), 0},
{6, "Lipsum", "", PX, AKEY, "Lorem Ipsum", lipsum(20009, 1), 0},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", lipsum(20010, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", lipsum(20011, 1), 0},
{6, "Lipsum", "", PX, AKEY, "Lorem Ipsum", lipsum(20012, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", lipsum(20013, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", lipsum(20014, 1), timeext.FromHours(-2.33)},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", lipsum(20015, 1), 0},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", lipsum(20016, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20001, 1), 0},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20002, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20003, 1), 0},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20004, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20005, 1), 0},
{6, "Lipsum", "", P1, AKEY, "Lorem Ipsum", Lipsum(20006, 1), 0},
{6, "Lipsum", "", P1, AKEY, "Lorem Ipsum", Lipsum(20007, 1), timeext.FromHours(-3.39)},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20008, 1), 0},
{6, "Lipsum", "", PX, AKEY, "Lorem Ipsum", Lipsum(20009, 1), 0},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20010, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20011, 1), 0},
{6, "Lipsum", "", PX, AKEY, "Lorem Ipsum", Lipsum(20012, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20013, 1), 0},
{6, "Lipsum", "", P2, SKEY, "Lorem Ipsum", Lipsum(20014, 1), timeext.FromHours(-2.33)},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20015, 1), 0},
{6, "Lipsum", "", P0, SKEY, "Lorem Ipsum", Lipsum(20016, 1), 0},
{7, "", "localhost", P2, SKEY, "Server outage resolution update", "We are still working on resolving the server outage and will provide updates as soon as possible", 0},
{7, "", "localhost", P0, SKEY, "New server release update", "A new update for the server has been released. Please update to the latest version for optimal performance", 0},
@ -369,6 +369,10 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
return DefData{User: users}
}
func lipsum(seed int64, paracount int) string {
func Lipsum(seed int64, paracount int) string {
return loremipsum.NewWithSeed(seed).Paragraphs(paracount)
}
func Lipsum0(paracount int) string {
return loremipsum.NewWithSeed(0).Paragraphs(paracount)
}